commit 46c3330e28bd0561668e313a550dccf5977f624b Author: wehub-resource-sync Date: Mon Jul 13 13:04:19 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b3ebfcb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Keep platform-specific scripts with safe line endings. +*.sh text eol=lf +*.command text eol=lf +*.ps1 text eol=crlf +*.bat text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47a6807 --- /dev/null +++ b/.gitignore @@ -0,0 +1,152 @@ +temp/ +tmp/ + +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +env/ +build/ +dist/ +*.egg-info/ + +.streamlit/ + +.vscode/ +.idea/ +*.swp +*.swo + +.DS_Store +Thumbs.db + +*.log +.env +auth.json +model_responses.txt + +# Sensitive files (API keys, credentials) +mykey.py + +tasks/ + +*.zip +*.exe + +memory/* +!memory/memory_management_sop.md + +# Allow tracking of specific SOPs +!memory/web_setup_sop.md +!memory/autonomous_operation_sop.md +!memory/autonomous_operation_sop/ +!memory/autonomous_operation_sop/** +!memory/scheduled_task_sop.md + +# L4 session archiver (only the script, not archives) +!memory/L4_raw_sessions/ +memory/L4_raw_sessions/* +!memory/L4_raw_sessions/compress_session.py +!memory/L4_raw_sessions/salient_mining_sop.md + +# ljqCtrl related tools +!memory/ljqCtrl.py +!memory/ljqCtrl_sop.md +!memory/macljqCtrl.py +!memory/computer_use.md +!memory/ljqCtrlBg.py + +# procmem_scanner related tools +!memory/procmem_scanner.py +!memory/procmem_scanner_sop.md + +# TMWebDriver SOP +!memory/tmwebdriver_sop.md + +# Vue3 Component SOP +!memory/vue3_component_sop.md + +# Subagent SOP +!memory/subagent.md + +# Incubator SOP +!memory/incubator_sop.md + +# Supervisor SOP +!memory/supervisor_sop.md + +# Plan SOP +!memory/plan_sop.md + +# UltraPlan SOP +!memory/ultraplan_sop.md + +# Goal Mode SOP +!memory/goal_mode_sop.md + +# Goal Hive Mode SOP +!memory/goal_hive_sop.md +!memory/goal_hive_master_duty.md + +# Morphling SOP +!memory/morphling_sop.md + + +# ADB UI tool +!memory/adb_ui.py + +# Keychain +!memory/keychain.py + +# Vision / OCR / UI detection tools +!memory/ocr_utils.py +!memory/vision_sop.md +!memory/ui_detect.py +!memory/vision_api.template.py + +# Memory management +!memory/memory_cleanup_sop.md + +# Checklist/MapReduce +!memory/checklist_helper.py +!memory/checklist_sop.md + +# Code Review Principles +!memory/code_review_principles.md + +# Review Mode SOP +!memory/review_sop.md + +# Visual Studio +.vs/ +restore_commit.txt + +sche_tasks/ +# CDP Bridge 密钥配置(首次运行自动生成) +assets/tmwd_cdp_bridge/config.js +assets/copilot_proxy.pyw +**log.* + +# Reflect (ignore new files, whitelist existing) +reflect/* +!reflect/autonomous.py +!reflect/scheduler.py +!reflect/agent_team_worker.py +!reflect/goal_mode.py +!reflect/checklist_master.py + +# Conductor IM plugins: 私有插件/配置不入库。示例为 _email_example.py / _lark_example.py +# (以 _ 开头不会被加载器自动轮询)。去掉下划线/示例后缀复制为 email.py / lark.py 即启用。 +frontends/conductor_im_plugins/* + +# Universal: never track __pycache__ anywhere +**/__pycache__/ + +.claude/ + +# Project Mode SOP +!memory/project_mode_sop.md + +# 本地 bug / 局限报告草稿(交给维护者参考,不进仓库) +/BUG_*.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fb9f411 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing to GenericAgent + +## Why This File Is Short + +GenericAgent's core is ~3K lines. Every file in this repo will be read by AI agents — potentially thousands of times. Extra words cost real tokens and push useful context out of the window, increasing hallucinations. This document practices what it preaches: **say only what matters.** + +## Before You Contribute + +1. **Read the codebase first.** It's small enough to read in one sitting. Understand the philosophy before proposing changes. +2. **Open an Issue first** for anything non-trivial. Discuss before coding. + +## Code Standards + +All PRs go through a strict automated code review skill. Key expectations: + +- **Self-documenting code, minimal comments.** If code needs a paragraph to explain, rewrite it. +- **Compact and visually uniform.** Fewer lines, consistent line lengths, no fluff. +- **Small change radius.** Changing A shouldn't ripple through B, C, D. +- **More features → less code.** Good abstractions make the codebase shrink, not grow. +- **Let it crash by failure radius.** Critical errors fail loud; trivial ones pass silently. No blanket try-catch. + +> ⚠️ This review is deliberately strict — most AI-generated code (e.g. Claude Code output) will not pass as-is. Read the full principles before submitting. + +## Skill Contributions + +GenericAgent evolves through skills. Not all skills belong in the core repo: + +| Type | Where it goes | Example | +|---|---|---| +| **Fundamental / universal** | Core repo (`memory/`) | File search, clipboard, basic web ops | +| **Domain-specific / niche** | Skill Marketplace *(coming soon)* | Stock screening, food delivery, specific API integrations | + +If your skill only makes sense for a specific workflow, it's a marketplace candidate, not a core PR. + +## PR Checklist + +- [ ] Issue linked or context explained in ≤3 sentences +- [ ] Code passes the [review principles] self-check: + 1. Can I safely modify this locally without reading the whole codebase? + 2. Is there a clear core abstraction — new features add implementations, not modify old logic? + 3. Are change points converging at boundaries, not scattered everywhere? + 4. On failure, can I quickly locate the responsible module? +- [ ] Net line count: ideally negative or zero for refactors +- [ ] No unnecessary dependencies added diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..641d051 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 lsdefine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0697dea --- /dev/null +++ b/README.md @@ -0,0 +1,818 @@ +
+ +GenericAgent Banner + +# GenericAgent + +**A Minimal, Self-Evolving Autonomous Agent Framework** + +*~3K lines of seed code · 9 atomic tools · ~100-line Agent Loop* + +

+ + Official Website + Technical Report + Reproduction Repo + Tutorial + Sophub +

+ +

+ Trendshift +

+ +**[English](#-english) · [中文](#-中文)** + +
+ +> 📌 **Official:** GitHub + https://gaagent.ai only. DintalClaw is the sole authorized commercial partner; others are not affiliated. + +--- + + + +## 🌟 Overview + +**GenericAgent** is a minimal, self-evolving autonomous agent framework. Its core is just **~3K lines of code**. Through **9 atomic tools + a ~100-line Agent Loop**, it grants any LLM system-level control over a local computer — covering browser, terminal, filesystem, keyboard/mouse input, screen vision, and mobile devices (ADB). + +> Design philosophy — **don't preload skills, evolve them.** + +Every time GenericAgent solves a new task, it automatically crystallizes the execution path into a reusable **Skill**. The longer you use it, the more skills accumulate — forming a personal skill tree grown entirely from 3K lines of seed code. + +> 🤖 **Self-Bootstrap Proof** — Everything in this repository, from installing Git and running `git init` to every commit message, was completed autonomously by GenericAgent. The author never opened a terminal once. + +### 📑 Table of Contents + +- [Key Features](#-key-features) +- [Demo Showcase](#-demo-showcase) +- [Quick Start](#-quick-start) +- [Usage](#-usage) +- [Unlocking Advanced Capabilities](#-unlocking-advanced-capabilities) +- [Architecture](#-architecture) +- [Self-Evolution Mechanism](#-self-evolution-mechanism) +- [Comparison](#-comparison) +- [Evaluation](#-evaluation) +- [Roadmap & News](#-roadmap--news) +- [Community & Support](#-community--support) +- [License](#-license) + +--- + +## 📋 Key Features + +| Feature | Description | +| :--- | :--- | +| 🧬 **Self-Evolving** | Automatically crystallizes each task into a Skill. Capabilities grow with every use, forming your personal skill tree. | +| 🪶 **Minimal Architecture** | ~3K lines of core code. Agent Loop is ~100 lines. No complex dependencies, zero deployment overhead. | +| ⚡ **Strong Execution** | **TMWebdriver** injects into a real browser (preserving login sessions). 9 atomic tools take direct control of the system. | +| 🔌 **High Compatibility** | Supports Claude / Gemini / Kimi / MiniMax and other major models. Cross-platform. | +| 💰 **Token Efficient** | <30K context window — a fraction of the 200K–1M other agents consume. Less noise, fewer hallucinations, higher success rate, lower cost. | + +--- + +## 🎯 Demo Showcase + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
🛡️ Real-Browser CAPTCHA Survival🌐 Autonomous Web Exploration
Discord hCaptcha passed in real browserWeb Exploration
While configuring a Discord bot, an hCaptcha "Are you human?" challenge pops up mid-task — GA's real browser session passes it and the task continues. See Browser Realness.Autonomously browses and periodically summarizes web content.
🧋 Food Delivery Order📈 Quantitative Stock Screening
Order TeaStock Selection
"Order me a milk tea" — navigates the delivery app, selects items, completes checkout."Find GEM stocks with EXPMA golden cross, turnover > 5%" — quantitative screening.
💰 Expense Tracking💬 Batch Messaging
Alipay ExpenseWeChat Batch
"Find expenses over ¥2K in the last 3 months" — drives Alipay via ADB.Sends bulk WeChat messages, fully driving the WeChat client.
+ +--- + +## 🚀 Quick Start + +> ⚠️ **Python version**: use **Python 3.11 or 3.12**. **Do not** use Python 3.14 — it is incompatible with `pywebview` and a few other GA dependencies. +> +> 📖 Detailed installation guide: **[installation.md](docs/installation.md)** · **[installation_zh.md(中文)](docs/installation_zh.md)** + +### For LLM Agents + +Fetch the installation guide and follow it: + +```bash +curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/main/docs/installation.md +``` + +### For Humans + +#### Method 1 — Clone & install *(recommended)* + +```bash +git clone https://github.com/lsdefine/GenericAgent.git && cd GenericAgent +uv venv && uv pip install -e ".[ui]" +cp mykey_template_en.py mykey.py # fill in your LLM API key +``` + +Dependencies are deliberately tiered: the agent core needs only `requests`, plus four lightweight packages (`beautifulsoup4`, `bottle`, `simple-websocket-server`, `aiohttp`) for TMWebdriver's local server. The `[ui]` extra pulls in frontend libraries (Streamlit, `prompt_toolkit`/`rich` for the TUI, …) — install it for the bundled UIs, or skip it entirely and drive the agent headless. No Playwright, no LangChain, no browser binaries to download. + +Then launch: + +```bash +python frontends/tui_v3.py # Terminal UI (recommended) +python launch.pyw # Streamlit web UI +``` + +#### Method 2 — One-line installer *(convenience)* + +Sets up a self-contained directory with an isolated Python environment, Git, and a ready-to-run package. The script is in [`assets/`](assets/) if you'd like to read it first. + +**Windows PowerShell** + +```powershell +powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.ps1 | iex" +``` + +**Linux / macOS** + +```bash +GLOBAL=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.sh)" +``` + +> 💡 GenericAgent grows its environment **through the Agent itself** — don't pre-install everything. See [Unlocking Advanced Capabilities](#-unlocking-advanced-capabilities) below. + +--- + +## 💻 Usage + +### Frontends + +#### Terminal UI *(recommended)* + +A lightweight, scrollback-first terminal interface built on `prompt_toolkit` + `rich`. Supports multiple concurrent sessions and real-time streaming. + +```bash +python frontends/tui_v3.py +``` + +
+⚠️ Windows TUI Troubleshooting + +TUI rendering on Windows can be flaky depending on terminal + font. Common causes: + +1. `prompt_toolkit` / `rich` are not on the latest version — `pip install -U prompt_toolkit rich` first. +2. PowerShell / cmd ship with terminals that have rough Unicode + key-binding support. **Prefer Git Bash on Windows**, which is much better behaved. +3. If it still looks broken, ask GA itself to fix it: + > *"My experience using `frontends/tui_v3.py` in PowerShell / cmd / Git Bash on Windows is very poor — lots of incompatibility. Please refer to Claude Code's best practices for the Windows terminal and fix all font and rendering incompatibilities."* + +
+ +#### Streamlit UI + +```bash +python launch.pyw +``` + +### Bot Interface (IM) + +GenericAgent also supports IM frontends such as Telegram, Discord, and Lark. + +| Platform | Command | +| :--- | :--- | +| Telegram | `python frontends/tgapp.py` | +| Discord | `python frontends/dcapp.py` | +| Lark / Feishu | `python frontends/fsapp.py` | + +> WeChat, QQ, WeCom and DingTalk are also supported — see the Chinese section below. +> For detailed setup, ask GenericAgent itself. + +--- + +## 🔓 Unlocking Advanced Capabilities + +In GA, advanced capabilities are unlocked by **instructing the agent**, not by reading +docs or installing extras. Each instruction below makes GA read its pre-installed SOPs +(battle-tested playbooks in its memory), install whatever is missing, adapt to your OS, +and persist the result into its own memory. + +| Capability | Just tell GA | +| :--- | :--- | +| 🌐 Web automation | *"Set up your web automation capability."* — GA guides you through the one manual step: dragging the bundled Chrome extension into `chrome://extensions`. | +| 🔤 OCR | *"Set up your OCR capability with rapidocr and save it to memory."* | +| 👁️ Vision | *"Set up your vision capability from the template in memory/."* — GA copies the template, wires it to your existing LLM keys, and self-tests. | +| 🖱️ Computer use | *"Probe this system and set up your computer-use capability."* | + +> 💡 **About language**: the pre-installed SOPs are written in Chinese — GA reads them +> natively, so this never blocks you. If you prefer an English knowledge base, just say: +> *"Read your pre-installed SOPs and rewrite them in English (keep code, paths and error +> strings verbatim)."* +> +> 🌍 **About platforms**: the SOPs were honed on Windows, but cross-platform adaptation is +> itself a GA task — on macOS/Linux, GA swaps in the platform equivalents (window +> enumeration, input control, screenshots) on its own. Same self-evolution principle. + +--- + +## 🧠 Architecture + +GenericAgent accomplishes complex tasks through **Layered Memory × Minimal Toolset × Autonomous Execution Loop**, continuously accumulating experience during execution. + +### 1️⃣ Layered Memory System + +> *Memory crystallizes throughout task execution, letting the agent build stable, efficient working patterns over time.* + +| Layer | Name | Description | +| :---: | :--- | :--- | +| **L0** | Meta Rules | Core behavioral rules and system constraints | +| **L1** | Insight Index | Minimal memory index for fast routing and recall | +| **L2** | Global Facts | Stable knowledge accumulated over long-term operation | +| **L3** | Task Skills / SOPs | Reusable workflows for completing specific task types | +| **L4** | Session Archive | Archived task records distilled from finished sessions for long-horizon recall | + +### 2️⃣ Autonomous Execution Loop + +> *Perceive environment state → Task reasoning → Execute tools → Write experience to memory → Loop* + +The entire core loop is just **~100 lines of code** ([`agent_loop.py`](agent_loop.py)). + +### 3️⃣ Minimal Toolset + +> *GenericAgent provides only **9 atomic tools**, forming the foundational capabilities for interacting with the outside world.* + +| Tool | Function | +| :--- | :--- | +| `code_run` | Execute arbitrary code (Python / PowerShell) | +| `file_read` | Read files | +| `file_write` | Write / create / overwrite files | +| `file_patch` | Patch / modify files | +| `web_scan` | Perceive web content | +| `web_execute_js` | Control browser behavior | +| `ask_user` | Human-in-the-loop confirmation | +| `update_working_checkpoint` | *(memory)* Short-term working notepad | +| `start_long_term_update` | *(memory)* Distill long-term memory | + +### 4️⃣ Capability Extension + +> *Capable of dynamically creating new tools.* + +Via `code_run`, GenericAgent can dynamically install Python packages, write new scripts, call external APIs, or control hardware at runtime — crystallizing temporary abilities into permanent tools. + +
+ GenericAgent Workflow +
GenericAgent Workflow Diagram +
+ +--- + +## 🧬 Self-Evolution Mechanism + +This is what fundamentally distinguishes GenericAgent from every other agent framework. + +```text +[New Task] + │ + ▼ +[Autonomous Exploration] ─► install deps · write scripts · debug · verify + │ + ▼ +[Crystallize into Skill] ─► write to memory layer + │ + ▼ +[Direct Recall on Next Similar Task] +``` + +| What you say | First time | Every time after | +| :--- | :--- | :--- | +| *"Read my WeChat messages"* | Install deps → reverse DB → write read script → save Skill | **one-line invoke** | +| *"Give me a morning digest of Hacker News"* | Write scraper → build digest → schedule daily run → save Skill | **one-line invoke** | +| *"Monitor stocks and alert me"* | Install `mootdx` → build selection flow → configure cron → save Skill | **one-line start** | +| *"Send this file via Gmail"* | Configure OAuth → write send script → save Skill | **ready to use** | + +After a few weeks, your agent instance will have a skill tree no one else in the world has — all grown from 3K lines of seed code. + +--- + +## 📊 Comparison + +| Feature | **GenericAgent** | OpenClaw | Claude Code | +| :--- | :---: | :---: | :---: | +| **Codebase** | ~3K lines | ~530,000 lines | Open-sourced (large) | +| **Deployment** | `pip install` + API Key | Multi-service orchestration | CLI + subscription | +| **Browser Control** | Real browser (session preserved) | Sandbox / headless browser | Via MCP plugin | +| **OS Control** | Mouse/kbd, vision, ADB | Multi-agent delegation | File + terminal | +| **Self-Evolution** | Autonomous skill growth | Plugin ecosystem | Stateless between sessions | +| **Out of the Box** | Few core files + starter skills | Hundreds of modules | Rich CLI toolset | + +--- + +## 📈 Evaluation + +> 📂 Full evaluation datasets and results: [**JinyiHan99/GA-Technical-Report**](https://github.com/JinyiHan99/GA-Technical-Report/tree/main) + +We evaluate GenericAgent across **five dimensions**: + +| # | Dimension | Question | Benchmarks | +| :---: | :--- | :--- | :--- | +| 1 | **Task Completion & Token Efficiency** | Can GA complete hard tasks more cheaply than leading agents? | SOP-Bench, Lifelong AgentBench, RealFin-Benchmark | +| 2 | **Tool-Use Efficiency** | Can a minimal atomic toolset solve what specialized toolsets solve, with less overhead? | Tool Efficiency Benchmark (11 simple + 5 long-horizon) | +| 3 | **Memory System Effectiveness** | Does condensed hierarchical memory beat full/redundant memory and embedding-based retrievers? | SOP-Bench (dangerous goods), LoCoMo, 20-skill stress test | +| 4 | **Self-Evolution Capability** | Can the agent distill experience into reusable SOPs and code, without intervention? | 9-round LangChain longitudinal study, 8-task cross-task web benchmark | +| 5 | **Web Browsing Capability** | Does density-driven design survive the open web? | WebCanvas, BrowseComp-ZH, Custom Tasks (22) | + +Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and **OpenClaw**, evaluated under *Claude Sonnet 4.6*, *Claude Opus 4.6*, *GPT-5.4*, and *MiniMax M2.7* backbones. + + + + + + +
+ Tool-use efficiency radar
+ Tool-use efficiency radar. GA dominates token, request, and tool-call axes while preserving quality across four task dimensions. +
+ Cross-task self-evolution convergence
+ Cross-task self-evolution. Second- and third-run GA executions converge to a stable low-cost regime across eight web tasks, while OpenClaw shows no such convergence. +
+ +### Browser Realness of GA Web Tools (TMWebdriver) + +GA web tools are powered by **TMWebdriver** — a local WebSocket server plus a Chrome extension — running through a **real, persistent Chrome/Chromium session** rather than a disposable headless sandbox, preserving cookies, login state, extensions, GPU/WebGL behavior, and normal browser-session fingerprints. + +| Detection Service / Signal | Vanilla Headless Automation | GA Web Tools | Notes | +| :--- | :---: | :---: | :--- | +| SannySoft headless test | Often detected | ✅ 56/56 passed | `bot.sannysoft.com` | +| bot.incolumitas.com | Commonly fails webdriver / CDP checks | ✅ 36/36 passed | `WEBDRIVER`, `SELENIUM_DRIVER`, `webDriverAdvanced` all OK | +| BrowserScan bot detection | Often abnormal | ✅ Normal | `browserscan.net` | +| Device & Browser Info bot test | Multiple bot flags | ✅ Human / `isBot=false` | `deviceandbrowserinfo.com` | +| FingerprintJS bot detection demo | Often detected | ✅ Passed | Demo flow completed without bot verdict | +| reCAPTCHA v3 demo | Low bot-like score | ✅ 0.9 human-like score | Score-based risk signal; 0.9 is above typical production thresholds | + +For reCAPTCHA v3, `0.9` is not a "checkbox solved" result; it is the high-confidence human-like score returned by the risk model, typically sufficient to avoid extra challenges in production flows. + +--- + +## 📅 Roadmap & News + +- **2026-05-23** — 🆕 **TUI v3 released** (`frontends/tui_v3.py`). Block-based scrollback with proper resize reflow, per-terminal color profile for cross-terminal parity, and feature parity with v2. +- **2026-05-18** — 🆕 **Morphling mode**. Project-level skill absorption — extract goal + tests from any external repo, then decide per component: call, rewrite, or discard. See `memory/morphling_sop.md`. +- **2026-05-17** — 🆕 **Goal Hive mode**. Multi-worker cooperative Goal mode — BBS-coordinated master/workers running long-horizon objectives in parallel. See `memory/goal_hive_sop.md`. +- **2026-05-15** — 🖥️ **Desktop GUI released**. One-line installs ship a ready-to-run desktop app (`frontends/GenericAgent.exe`). Developers launch via `python launch.pyw`. +- **2026-05-14** — 🆕 **Conductor sub-agent orchestration**. Spawn, supervise, and auto-clean parallel sub-agents; first-class delegation primitives complementing `/btw` side-questions. +- **2026-05-12** — 🆕 **TUI v2 released** (`frontends/tuiapp_v2.py`). Refined Textual frontend with image-paste folding, file paste, block-delete, Ctrl+C copy, history navigation, and `/llm` / `/export` / `/continue` pickers. +- **2026-05-08** — 🆕 **Goal mode** (`reflect/goal_mode.py`). Time-budget-driven self-driven loop — "keep optimizing X for N hours" with no premature delivery. +- **2026-04-21** — 📄 [**Technical Report on arXiv**](https://arxiv.org/abs/2604.17091) — *GenericAgent: A Token-Efficient Self-Evolving LLM Agent via Contextual Information Density Maximization*. +- **2026-04-11** — Introduced **L4 session archive memory** and scheduler cron integration. +- **2026-03-23** — Personal WeChat supported as a bot frontend. +- **2026-03-10** — [Released million-scale Skill Library](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7) *(Chinese)*. +- **2026-03-08** — [Released "Dintal Claw" — a GenericAgent-powered government-affairs bot](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg) *(Chinese)*. +- **2026-03-01** — [Featured by Jiqizhixin (机器之心)](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg) *(Chinese)*. +- **2026-01-16** — GenericAgent **V1.0** public release. + +--- + +## ⭐ Community & Support + +If this project helped you, please consider leaving a **Star!** 🙏 + +### 🚩 Friendly Links + +Thanks to the **LinuxDo** community for the support! + +[![LinuxDo](https://img.shields.io/badge/Community-LinuxDo-blue?style=for-the-badge)](https://linux.do/) + +**Community GUIs** *(independent open-source projects)*: + +- [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager) +- [wangjc683/galley](https://github.com/wangjc683/galley) — Out-of-the-box local agent workbench with a bundled GA runtime (CPython 3.11 + deps), native GUI/CLI, multi-session + Project orchestration, local-first. +- [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench) +- [Fwind43/GenericAgent-Admin](https://github.com/Fwind43/GenericAgent-Admin) — Go + React desktop admin panel: service lifecycle management, native chat, Goal mode, BBS team board, file editor, model config wizard, TMWebDriver monitor, self-update, and Windows tray/desktop-pet integration. + +--- + +## 📄 License + +Distributed under the **MIT License**. See [`LICENSE`](LICENSE) for full text. + +> *Disclaimer: The official GenericAgent channels are this GitHub repository and https://gaagent.ai. DintalClaw is currently the only officially authorized commercial partner; any other third-party website, organization, or individual using the GenericAgent name is not official unless explicitly listed here.* + +--- + + + +## 🌟 项目简介 + +**GenericAgent** 是一个极简、可自我进化的自主 Agent 框架。核心仅 **~3K 行代码**,通过 **9 个原子工具 + ~100 行 Agent Loop**,赋予任意 LLM 对本地计算机的系统级控制能力,覆盖浏览器、终端、文件系统、键鼠输入、屏幕视觉及移动设备(ADB)。 + +> 设计哲学 —— **不预设技能,靠进化获得能力。** + +每解决一个新任务,GenericAgent 就将执行路径自动固化为 Skill,供后续直接调用。使用时间越长,沉淀的技能越多,形成一棵完全属于你、从 3K 行种子代码生长出来的专属技能树。 + +> 🤖 **自举实证** — 本仓库的一切,从安装 Git、`git init` 到每一条 commit message,均由 GenericAgent 自主完成。作者全程未打开过一次终端。 + +### 📑 目录 + +- [核心特性](#-核心特性) +- [实例展示](#-实例展示) +- [快速开始](#-快速开始) +- [使用方式](#-使用方式) +- [架构设计](#-架构设计) +- [自我进化机制](#-自我进化机制) +- [与同类产品对比](#-与同类产品对比) +- [评测](#-评测) +- [路线图与最新动态](#-路线图与最新动态) +- [社区与支持](#-社区与支持) +- [许可](#-许可) + +--- + +## 📋 核心特性 + +| 特性 | 说明 | +| :--- | :--- | +| 🧬 **自我进化** | 每次任务自动沉淀 Skill,能力随使用持续增长,形成专属技能树 | +| 🪶 **极简架构** | ~3K 行核心代码,Agent Loop 约百行,无复杂依赖,部署零负担 | +| ⚡ **强执行力** | 注入真实浏览器(保留登录态),9 个原子工具直接接管系统 | +| 🔌 **高兼容性** | 支持 Claude / Gemini / Kimi / MiniMax 等主流模型,跨平台运行 | +| 💰 **极致省 Token** | 上下文窗口不到 30K,是其他 Agent(200K–1M)的零头;噪声更少、幻觉更低、成功率更高,成本低一个数量级 | + +--- + +## 🎯 实例展示 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
🧋 外卖下单📈 量化选股
外卖下单量化选股
"Order me a milk tea" — 自动导航外卖 App,选品并完成结账"Find GEM stocks with EXPMA golden cross, turnover > 5%" — 量化条件筛股
🌐 自主网页探索💰 支出追踪
网页探索支付宝支出
自主浏览并定时汇总网页信息"查找近 3 个月超 ¥2K 的支出" — 通过 ADB 驱动支付宝
💬 批量消息
微信批量
批量发送微信消息,完整驱动微信客户端
+ +--- + +## 🚀 快速开始 + +> ⚠️ **Python 版本:** 推荐使用 **Python 3.11 或 3.12**。**请不要使用 Python 3.14**,与 `pywebview` 及部分依赖不兼容。 +> +> 📖 详细安装指南:**[installation_zh.md(中文)](docs/installation_zh.md)** · **[installation.md (English)](docs/installation.md)** + +### 给 LLM Agent 看的 + +获取安装指南并照做: + +```bash +curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/main/docs/installation_zh.md +``` + +### 给人类用户看的 + +#### 方法一 — 一键安装 *(推荐)* + +一键安装会自动准备独立 Python 环境、Git、项目文件和桌面端,不污染系统环境。 + +**Windows PowerShell** + +```powershell +powershell -ExecutionPolicy Bypass -c "irm http://fudankw.cn:9000/files/ga_install.ps1 | iex" +``` + +**Linux / macOS** + +```bash +curl -fsSL http://fudankw.cn:9000/files/ga_install.sh | bash +``` + +安装完成后启动: + +- **Windows** — 双击 `frontends/GenericAgent.exe` +- **Linux / macOS** — 在安装目录运行 `python launch.pyw` + +#### 方法二 — Python 安装 *(开发者)* + +```bash +git clone https://github.com/lsdefine/GenericAgent.git +cd GenericAgent +uv venv +uv pip install -e ".[ui]" # 核心 + UI 依赖 +cp mykey_template.py mykey.py # 填入你的 LLM API Key +python launch.pyw +``` + +> 💡 GenericAgent 更推荐由 **Agent 在使用中自举环境**,而不是预先手动装完整依赖。 + +📖 完整引导流程见 [`docs/GETTING_STARTED.md`](docs/GETTING_STARTED.md) +📖 新手图文版:[飞书文档](https://my.feishu.cn/wiki/CGrDw0T76iNFuskmwxdcWrpinPb) +📘 完整入门教程(Datawhale 出品):[Hello GenericAgent](https://datawhalechina.github.io/hello-generic-agent/) · [GitHub](https://github.com/datawhalechina/hello-generic-agent) + +--- + +## 💻 使用方式 + +### 前端启动 + +#### 桌面端 + +一键安装自带桌面端(Windows),双击: + +```text +frontends/GenericAgent.exe +``` + +#### 终端 UI + +基于 [Textual](https://github.com/Textualize/textual) 的轻量键盘驱动界面。支持多会话并发、实时流式输出,有终端就能跑。 + +```bash +python frontends/tuiapp_v2.py +``` + +
+⚠️ Windows 上 TUI 显示异常的排查思路 + +1. `textual` 版本太旧,先 `pip install -U textual`; +2. PowerShell / cmd 自带终端对 Unicode 和键位的支持比较糟糕,**Windows 上推荐用 Git Bash**,体验明显更稳; +3. 仍然显示异常时,可以让 GA 自己修一遍,参考 Prompt: + > *"我在 Windows 的 PowerShell / cmd / Git Bash 中使用 `frontends/tuiapp_v2.py` 体验非常差,出现了一堆不兼容问题。请参考 Claude Code 在 Windows 终端的最佳配置,把所有字体和显示不兼容的问题修一遍。"* + +
+ +#### Streamlit UI + +```bash +python launch.pyw +``` + +### Bot 接口(IM) + +GenericAgent 支持 Telegram、Discord、微信、QQ、飞书 / Lark、企业微信、钉钉等 IM 前端。 + +| 平台 | 启动命令 | +| :--- | :--- | +| Telegram | `python frontends/tgapp.py` | +| Discord | `python frontends/dcapp.py` | +| 微信 | `python frontends/wechatapp.py` | +| QQ | `python frontends/qqapp.py` | +| 飞书 / Lark | `python frontends/fsapp.py` | +| 企业微信 | `python frontends/wecomapp.py` | +| 钉钉 | `python frontends/dingtalkapp.py` | + +> 详细配置直接问 GenericAgent。 + +--- + +## 🧠 架构设计 + +GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环** 完成复杂任务,并在执行过程中持续积累经验。 + +### 1️⃣ 分层记忆系统 + +> *记忆在任务执行过程中持续沉淀,使 Agent 逐步形成稳定且高效的工作方式。* + +| 层级 | 名称 | 说明 | +| :---: | :--- | :--- | +| **L0** | 元规则(Meta Rules) | Agent 的基础行为规则和系统约束 | +| **L1** | 记忆索引(Insight Index) | 极简索引层,用于快速路由与召回 | +| **L2** | 全局事实(Global Facts) | 在长期运行过程中积累的稳定知识 | +| **L3** | 任务 Skills / SOPs | 完成特定任务类型的可复用流程 | +| **L4** | 会话归档(Session Archive) | 从已完成任务中提炼出的归档记录,用于长程召回 | + +### 2️⃣ 自主执行循环 + +> *感知环境状态 → 任务推理 → 调用工具执行 → 经验写入记忆 → 循环* + +整个核心循环仅 **约百行代码**([`agent_loop.py`](agent_loop.py))。 + +### 3️⃣ 最小工具集 + +> *GenericAgent 仅提供 **9 个原子工具**,构成与外部世界交互的基础能力。* + +| 工具 | 功能 | +| :--- | :--- | +| `code_run` | 执行任意代码(Python / PowerShell) | +| `file_read` | 读取文件 | +| `file_write` | 写入 / 创建 / 覆盖文件 | +| `file_patch` | 修改文件 | +| `web_scan` | 感知网页内容 | +| `web_execute_js` | 控制浏览器行为 | +| `ask_user` | 人机协作确认 | +| `update_working_checkpoint` | *(记忆)* 短期工作记事板 | +| `start_long_term_update` | *(记忆)* 提炼长期记忆 | + +### 4️⃣ 能力扩展机制 + +> *具备动态创建新工具的能力。* + +通过 `code_run`,GenericAgent 可在运行时动态安装 Python 包、编写新脚本、调用外部 API 或控制硬件,将临时能力固化为永久工具。 + +
+ GenericAgent 工作流程 +
GenericAgent 工作流程图 +
+ +--- + +## 🧬 自我进化机制 + +这是 GenericAgent 区别于其他 Agent 框架的根本所在。 + +```text +[遇到新任务] + │ + ▼ +[自主摸索] ─► 安装依赖 · 编写脚本 · 调试验证 + │ + ▼ +[执行路径固化为 Skill] ─► 写入记忆层 + │ + ▼ +[下次同类任务直接调用] +``` + +| 你说的一句话 | 第一次做了什么 | 之后每次 | +| :--- | :--- | :--- | +| *"监控股票并提醒我"* | 安装 `mootdx` → 构建选股流程 → 配置定时任务 → 保存 Skill | **一句话启动** | +| *"用 Gmail 发这个文件"* | 配置 OAuth → 编写发送脚本 → 保存 Skill | **直接可用** | + +用几周后,你的 Agent 实例将拥有一套任何人都没有的专属技能树,全部从 3K 行种子代码中生长而来。 + +--- + +## 📊 与同类产品对比 + +| 特性 | **GenericAgent** | OpenClaw | Claude Code | +| :--- | :---: | :---: | :---: | +| **代码量** | ~3K 行 | ~530,000 行 | 已开源(体量大) | +| **部署方式** | `pip install` + API Key | 多服务编排 | CLI + 订阅 | +| **浏览器控制** | 注入真实浏览器(保留登录态) | 沙箱 / 无头浏览器 | 通过 MCP 插件 | +| **OS 控制** | 键鼠、视觉、ADB | 多 Agent 委派 | 文件 + 终端 | +| **自我进化** | 自主生长 Skill 和工具 | 插件生态 | 会话间无状态 | +| **出厂配置** | 几个核心文件 + 少量初始 Skills | 数百模块 | 丰富 CLI 工具集 | + +--- + +## 📈 评测 + +> 📂 完整的评测数据集以及评测结果见:[**JinyiHan99/GA-Technical-Report**](https://github.com/JinyiHan99/GA-Technical-Report/tree/main) + +我们从 **五大维度** 评测 GenericAgent: + +| # | 维度 | 核心问题 | 使用的基准 | +| :---: | :--- | :--- | :--- | +| 1 | **任务完成度与 Token 效率** | GA 能否以更低成本完成高难度任务? | SOP-Bench、Lifelong AgentBench、RealFin-Benchmark | +| 2 | **工具使用效率** | 最小原子工具集能否以更低开销替代专用工具集? | Tool Efficiency Benchmark | +| 3 | **记忆系统有效性** | 精简分层记忆能否超越冗余记忆和基于 Embedding 的检索器? | SOP-Bench、LoCoMo、20-skill 压力测试 | +| 4 | **自我进化能力** | Agent 能否在无人干预下将经验提炼为可复用的 SOP 与代码? | 9 轮 LangChain 纵向研究、8 任务跨任务 Web 基准 | +| 5 | **网页浏览能力** | 信息密度驱动设计能否适应开放网页? | WebCanvas、BrowseComp-ZH、自定义任务 | + +以上维度的基线包括 **Claude Code**、**OpenAI CodeX** 和 **OpenClaw**,分别在 *Claude Sonnet 4.6*、*Claude Opus 4.6*、*GPT-5.4* 和 *MiniMax M2.7* 底座上进行评测。 + + + + + + +
+ 工具使用效率雷达图
+ 工具使用效率雷达图。GA 在 Token、请求数和工具调用轴上全面领先,同时在四个任务维度上保持质量。 +
+ 跨任务自我进化收敛曲线
+ 跨任务自我进化。GA 的第二轮和第三轮执行在 8 个 Web 任务上收敛至稳定的低成本区间。 +
+ +### GA Web 工具的浏览器真实性 + +GA Web 工具运行在**真实、持久化的 Chrome/Chromium 会话**中,而不是一次性的 headless 沙箱,因此可以保留 Cookie、登录态、扩展、GPU/WebGL 行为以及正常浏览器会话指纹。 + +| 检测服务 / 信号 | 普通 Headless 自动化 | GA Web 工具 | 说明 | +| :--- | :---: | :---: | :--- | +| SannySoft headless test | 常被识别 | ✅ 56/56 通过 | `bot.sannysoft.com` | +| bot.incolumitas.com | 常在 webdriver / CDP 项异常 | ✅ 36/36 通过 | `WEBDRIVER`、`SELENIUM_DRIVER`、`webDriverAdvanced` 全部 OK | +| BrowserScan bot detection | 常显示异常 | ✅ Normal | `browserscan.net` | +| Device & Browser Info bot test | 多个 bot 标记 | ✅ Human / `isBot=false` | `deviceandbrowserinfo.com` | +| FingerprintJS bot detection demo | 常被识别 | ✅ 通过 | Demo 流程完成,未给出 bot 判定 | +| reCAPTCHA v3 demo | 低分 / bot-like | ✅ 0.9 真人相似分 | v3 是基于分数的风险信号;0.9 高于常见生产阈值 | + +对于 reCAPTCHA v3,`0.9` 不是“点过验证码”的结果,而是风控模型返回的高置信真人相似分,通常足以通过生产环境中的常见阈值,避免进入更严格挑战。 + +--- + +## 📅 路线图与最新动态 + +- **2026-05-23** — 🆕 **TUI v3 正式发布**(`frontends/tui_v3.py`)。基于块的滚屏回看 + 正确的 resize 重排,每终端独立配色保证跨终端一致,并与 v2 达成功能对齐。 +- **2026-05-18** — 🆕 **Morphling 模式**。项目级能力吞噬 —— 从任意外部仓库抽取目标与测例后,对每个核心组件分别决定调用、重写或舍弃。详见 `memory/morphling_sop.md`。 +- **2026-05-17** — 🆕 **Goal Hive 模式**。多 worker 协作版 Goal —— Master/Worker 通过 BBS 协同推进长程目标。详见 `memory/goal_hive_sop.md`。 +- **2026-05-15** — 🖥️ **桌面 GUI 发布**。一键安装会自带可直接运行的桌面端(`frontends/GenericAgent.exe`),开发者也可用 `python launch.pyw` 启动。 +- **2026-05-14** — 🆕 **Conductor 子 Agent 编排**。派发、监督、自动清理并行子 Agent;与 `/btw` 旁路子 Agent 互补,提供一等公民级的任务委派原语。 +- **2026-05-12** — 🆕 **TUI v2 正式发布**(`frontends/tuiapp_v2.py`)。重做视觉风格的 Textual 前端,支持图片粘贴折叠、文件粘贴、块删除、Ctrl+C 复制、历史导航,以及 `/llm` / `/export` / `/continue` 选择器。 +- **2026-05-08** — 🆕 **Goal 模式**(`reflect/goal_mode.py`)。时间预算驱动的自驱循环 —— "持续优化 X N 小时",预算没到不准提前交付。 +- **2026-04-21** — 📄 [**技术报告已发布至 arXiv**](https://arxiv.org/abs/2604.17091) — *GenericAgent: A Token-Efficient Self-Evolving LLM Agent via Contextual Information Density Maximization*。 +- **2026-04-11** — 引入 **L4 会话归档记忆**,并接入 scheduler cron 调度。 +- **2026-03-23** — 支持个人微信接入作为 Bot 前端。 +- **2026-03-10** — [发布百万级 Skill 库](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7)。 +- **2026-03-08** — [发布以 GenericAgent 为核心的"政务龙虾" Dintal Claw](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg)。 +- **2026-03-01** — [被机器之心报道](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg)。 +- **2026-01-16** — GenericAgent **V1.0** 公开版本发布。 + +--- + +## ⭐ 社区与支持 + +如果这个项目对你有帮助,欢迎点一个 **Star!** 🙏 + +也欢迎加入 **GenericAgent 体验交流群**,一起交流、反馈、共建 👏 + +
+ + + + +
微信群 21
微信群 21 二维码
+
+ +### 🚩 友情链接 + +感谢 **LinuxDo** 社区的支持! + +[![LinuxDo](https://img.shields.io/badge/社区-LinuxDo-blue?style=for-the-badge)](https://linux.do/) + +**社区 GUI 客户端** *(独立开源项目)*: + +- [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager) +- [wangjc683/galley](https://github.com/wangjc683/galley) —— 开箱即用的本地 Agent 工作台,自带 GA 内核(内置 CPython 3.11 + 运行依赖),GUI/CLI 双原生、多 session + Project 编排、本地优先。 +- [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench) +- [Fwind43/GenericAgent-Admin](https://github.com/Fwind43/GenericAgent-Admin) —— Go + React 桌面管理面板:服务生命周期管理、原生 Chat、Goal 模式、BBS 团队看板、文件编辑器、模型配置向导、TMWebDriver 监控、自更新,以及 Windows 托盘/桌面宠物集成。 + +--- + +## 📄 许可 + +基于 **MIT License** 发布,详见 [`LICENSE`](LICENSE)。 + +> *声明:GenericAgent 官方渠道为本 GitHub 仓库和 https://gaagent.ai。DintalClaw 是目前唯一官方授权的商业合作方;除非在此处明确列出,其他使用 GenericAgent 名义的第三方网站、机构、组织或个人均非官方。* + +--- + +## 📈 Star History + +
+ + + + + + Star History Chart + + + +

+
diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..9e4dc97 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`lsdefine/GenericAgent` +- 原始仓库:https://github.com/lsdefine/GenericAgent +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/TMWebDriver.py b/TMWebDriver.py new file mode 100644 index 0000000..c14535a --- /dev/null +++ b/TMWebDriver.py @@ -0,0 +1,283 @@ +import json, threading, time, uuid, queue, socket, requests, traceback +from typing import Any +from simple_websocket_server import WebSocketServer, WebSocket +import bottle +from bottle import request + +class Session: + def __init__(self, session_id, info, client=None): + self.id = session_id + self.info = info + self.connect_at = time.time() + self.disconnect_at = None + self.type = info.get('type', 'ws') + self.ws_client = client if self.type in ('ws', 'ext_ws') else None + self.http_queue = client if self.type == 'http' else None + @property + def url(self): return self.info.get('url', '') + def is_active(self): + if self.type == 'http' and time.time() - self.connect_at > 60: self.mark_disconnected() + return self.disconnect_at is None + def reconnect(self, client, info): + self.info = info + self.type = info.get('type', 'ws') + if self.type in ('ws', 'ext_ws'): + self.ws_client = client + self.http_queue = None + elif self.type == 'http': + self.http_queue = client + self.connect_at = time.time() + self.disconnect_at = None + def mark_disconnected(self): + if self.disconnect_at is None: print(f"Tab disconnected: {self.url} (Session: {self.id})") + self.disconnect_at = time.time() + + +class TMWebDriver: + def __init__(self, host: str = '127.0.0.1', port: int = 18765): + self.host, self.port = host, port + self.sessions, self.results, self.acks = {}, {}, {} + self.default_session_id = None + self.latest_session_id = None + self.is_remote = socket.socket().connect_ex((host, port+1)) == 0 + if not self.is_remote: + self.start_ws_server() + self.start_http_server() + else: + self.remote = f'http://{self.host}:{self.port+1}/link' + + def start_http_server(self): + self.app = app = bottle.Bottle() + + @app.route('/api/longpoll', method=['GET', 'POST']) + def long_poll(): + data = request.json + session_id = data.get('sessionId') + session_info = {'url': data.get('url'), 'title': data.get('title', ''), 'type': 'http'} + if session_id not in self.sessions: + session = Session(session_id, session_info, queue.Queue()) + print(f"Browser http connected: {session.url} (Session: {session_id})") + self.sessions[session_id] = session + session = self.sessions[session_id] + if session.disconnect_at is not None and session.type != 'http': session.reconnect(queue.Queue(), session_info) + session.disconnect_at = None + if session.type == 'http': msgQ = session.http_queue + else: return json.dumps({"id": "", "ret": "use ws"}) + session.connect_at = start_time = time.time() + while time.time() - start_time < 5: + try: + msg = msgQ.get(timeout=0.2) + try: self.acks[json.loads(msg).get('id','')] = True + except Exception: traceback.print_exc() + return msg + except queue.Empty: continue + return json.dumps({"id": "", "ret": "next long-poll"}) + + @app.route('/api/result', method=['GET','POST']) + def result(): + data = request.json + if data.get('type') == 'result': + self.results[data.get('id')] = {'success': True, 'data': data.get('result'), 'newTabs': data.get('newTabs', [])} + elif data.get('type') == 'error': + self.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])} + return 'ok' + + @app.route('/link', method=['GET','POST']) + def link(): + data = request.json + if data.get('cmd') == 'get_all_sessions': return json.dumps({'r': self.get_all_sessions()}, ensure_ascii=False) + if data.get('cmd') == 'find_session': + url_pattern = data.get('url_pattern', '') + return json.dumps({'r': self.find_session(url_pattern)}, ensure_ascii=False) + if data.get('cmd') == 'execute_js': + session_id = data.get('sessionId') + code = data.get('code') + timeout = float(data.get('timeout', 10.0)) + try: result = self.execute_js(code, timeout=timeout, session_id=session_id) + except Exception as e: return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False) + try: print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' ')) + except Exception: pass + return json.dumps({'r': result}, ensure_ascii=False) + return 'ok' + def run(): + from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler + from socketserver import ThreadingMixIn + class _T(ThreadingMixIn, WSGIServer): pass + class _H(WSGIRequestHandler): + def log_request(self, *a): pass + make_server(self.host, self.port+1, app, server_class=_T, handler_class=_H).serve_forever() + http_thread = threading.Thread(target=run, daemon=True) + http_thread.start() + + def clean_sessions(self): + sids = list(self.sessions.keys()) + for sid in sids: + session = self.sessions[sid] + if not session.is_active() and time.time() - session.disconnect_at > 600: + del self.sessions[sid] + + def start_ws_server(self) -> None: + driver = self + class JSExecutor(WebSocket): + def handle(self) -> None: + try: + data = json.loads(self.data) + if data.get('type') == 'ready': + session_id = data.get('sessionId') + session_info = {'url': data.get('url'), 'title': data.get('title', ''), + 'connected_at': time.time(), 'type': 'ws'} + driver._register_client(session_id, self, session_info) + elif data.get('type') in ['ext_ready', 'tabs_update']: + tabs = data.get('tabs', []) + current_tab_ids = {str(tab['id']) for tab in tabs} + print(f"Received tabs update: {current_tab_ids}") + for sid in list(driver.sessions.keys()): + sess = driver.sessions[sid] + if sess.type == 'ext_ws' and sid not in current_tab_ids: + sess.mark_disconnected() + for tab in tabs: + session_id = str(tab['id']) + session_info = {'url': tab.get('url'), 'title': tab.get('title', ''), 'connected_at': time.time(), 'type': 'ext_ws'} + sess = driver.sessions.get(session_id) + if sess and sess.is_active(): sess.info = session_info + else: driver._register_client(session_id, self, session_info) + elif data.get('type') == 'ack': driver.acks[data.get('id','')] = True + elif data.get('type') == 'result': + driver.results[data.get('id')] = {'success': True, 'data': data.get('result'), 'newTabs': data.get('newTabs', [])} + elif data.get('type') == 'error': + driver.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])} + except Exception as e: + print(f"Error handling message: {e}") + if hasattr(self, 'data'): print(self.data) + def connected(self): (f"New connection from {self.address}") + def handle_close(self): + print(f"WS Connection closed: {self.address}") + driver._unregister_client(self) + + self.server = WebSocketServer(self.host, self.port, JSExecutor) + server_thread = threading.Thread(target=self.server.serve_forever) + server_thread.daemon = True + server_thread.start() + print(f"WebSocket server running on ws://{self.host}:{self.port}") + + def _register_client(self, session_id: str, client: WebSocket, session_info) -> None: + is_new_session = session_id not in self.sessions + + if is_new_session: + session = Session(session_id, session_info, client) + self.sessions[session_id] = session + print(f"New tab connected: {session.url} (Session: {session_id})") + else: + session = self.sessions[session_id] + session.reconnect(client, session_info) + print(f"Tab reconnected: {session.url} (Session: {session_id})") + + self.latest_session_id = session_id + if self.default_session_id is None: self.default_session_id = session_id + + def _unregister_client(self, client: WebSocket) -> None: + for session in self.sessions.values(): + if session.ws_client == client: session.mark_disconnected() + + def execute_js(self, code, timeout=15, session_id=None) -> Any: + if session_id is None: session_id = self.default_session_id + if self.is_remote: + print('remote_execute_js') + response = self._remote_cmd({"cmd": "execute_js", "sessionId": session_id, + "code": code, "timeout": str(timeout)}).get('r', {}) + if response.get('error'): raise Exception(response['error']) + return response + + session = self.sessions.get(session_id) + if not session or not session.is_active(): + time.sleep(3) + session = self.sessions.get(session_id) + if not session or not session.is_active(): + alive_sessions = [s for s in self.sessions.values() if s.is_active()] + if alive_sessions: + session = alive_sessions[0] + print(f"会话 {session_id} 未连接,自动切换到最新活动会话: {session.id}") + session_id = self.default_session_id = session.id + if not session or not session.is_active(): + raise ValueError(f"会话ID {session_id} 未连接") + + tp = session.type + if tp not in ('ws', 'http', 'ext_ws'): + raise ValueError(f"Unsupported session type: {tp}") + exec_id = str(uuid.uuid4()) + payload_dict = {'id': exec_id, 'code': code} + if tp == 'ext_ws': payload_dict['tabId'] = int(session.id) + payload = json.dumps(payload_dict) + + if tp in ['ws', 'ext_ws']: session.ws_client.send_message(payload) + elif tp == 'http': session.http_queue.put(payload) + + start_time = time.time() + self.clean_sessions() + hasjump = acked = False + + while exec_id not in self.results: + time.sleep(0.2) + if not acked and exec_id in self.acks: + acked = True; start_time = time.time() + if tp in ['ws', 'ext_ws']: + if not session.is_active(): hasjump = True + if hasjump and session.is_active(): + return {'result': f"Session {session_id} reloaded.", "closed":1} + if time.time() - start_time > timeout: + if tp in ['ws', 'ext_ws']: + if hasjump: return {'result': f"Session {session_id} reloaded and new page is loading...", 'closed':1} + if acked: return {"result": f"No response data in {timeout}s (ACK received, script may still be running)"} + return {"result": f"No response data in {timeout}s (no ACK, script may not have been delivered)"} + elif tp == 'http': + if acked: return {"result": f"Session {session_id} no response in {timeout}s (delivered but no result)"} + return {"result": f"Session {session_id} no response in {timeout}s (script not polled)"} + + result = self.results.pop(exec_id) + if exec_id in self.acks: self.acks.pop(exec_id) + if not result['success']: raise Exception(result['data']) + rr = {'data': result['data']} + newtabs = result.get('newTabs', []); [x.pop('ts', None) for x in newtabs] + if newtabs: rr['newTabs'] = newtabs + return rr + + def _remote_cmd(self, cmd): + try: return requests.post(self.remote, headers={"Content-Type": "application/json"}, json=cmd, timeout=30).json() + except (ConnectionError, requests.exceptions.ConnectionError): + raise ConnectionError("TMWebDriver master未运行,看tmwebdriver_sop后台启动一个TMWebDriver") + + def get_all_sessions(self): + if self.is_remote: + return self._remote_cmd({"cmd": "get_all_sessions"}).get('r', []) + return [{'id': session.id, **session.info} for session in self.sessions.values() + if session.is_active()] + + def get_session_dict(self): + return {session['id']: session['url'] for session in self.get_all_sessions()} + + def find_session(self, url_pattern: str): + if url_pattern == '': + session = self.sessions.get(self.latest_session_id) + return [(session.id, session.info)] if session else [] + matching_sessions = [] + for session in self.sessions.values(): + if not session.is_active(): continue + if 'url' in session.info and url_pattern in session.info['url']: + matching_sessions.append((session.id, session.info)) + return matching_sessions + + def set_session(self, url_pattern: str) -> bool: + if self.is_remote: + matched = self._remote_cmd({"cmd": "find_session", "url_pattern": url_pattern}).get('r', []) + else: + matched = self.find_session(url_pattern) + if not matched: return print(f"警告: 未找到URL包含 '{url_pattern}' 的会话") + if len(matched) > 1: print(f"警告: 找到多个URL包含 '{url_pattern}' 的会话,选择第一个") + self.default_session_id, info = matched[0] + print(f"成功设置默认会话: {self.default_session_id}: {info['url']}") + return self.default_session_id + + def jump(self, url, timeout=10): self.execute_js(f"window.location.href='{url}'", timeout=timeout) + +if __name__ == "__main__": + driver = TMWebDriver(host='127.0.0.1', port=18765) \ No newline at end of file diff --git a/agent_loop.py b/agent_loop.py new file mode 100644 index 0000000..3574e8e --- /dev/null +++ b/agent_loop.py @@ -0,0 +1,133 @@ +import json, re, os +from dataclasses import dataclass +from typing import Any, Optional +try: from plugins.hooks import trigger as _hook +except ImportError: _hook = lambda *a, **k: None +@dataclass +class StepOutcome: + data: Any + next_prompt: Optional[str] = None + should_exit: bool = False +def try_call_generator(func, *args, **kwargs): + ret = func(*args, **kwargs) + if hasattr(ret, '__iter__') and not isinstance(ret, (str, bytes, dict, list)): ret = yield from ret + return ret + +class BaseHandler: + def turn_end_callback(self, response, tool_calls, tool_results, turn, next_prompt, exit_reason): return next_prompt + def dispatch(self, tool_name, args, response, index=0, tool_num=1): + method_name = f"do_{tool_name}" + if hasattr(self, method_name): + args['_index'] = index; args['_tool_num'] = tool_num + _hook('tool_before', locals()) + ret = yield from try_call_generator(getattr(self, method_name), args, response) + _hook('tool_after', locals()) + return ret + elif tool_name == 'bad_json': return StepOutcome(None, next_prompt=args.get('msg', 'bad_json'), should_exit=False) + else: + yield f"未知工具: {tool_name}\n" + return StepOutcome(None, next_prompt=f"未知工具 {tool_name}", should_exit=False) + +def json_default(o): return list(o) if isinstance(o, set) else str(o) +def exhaust(g): + try: + while True: next(g) + except StopIteration as e: return e.value + +def get_pretty_json(data): + if isinstance(data, dict) and "script" in data: + data = data.copy(); data["script"] = data["script"].replace("; ", ";\n ") + return json.dumps(data, indent=2, ensure_ascii=False).replace('\\n', '\n') + +def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, + max_turns=40, verbose=True, initial_user_content=None, yield_info=False): + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": initial_user_content if initial_user_content is not None else user_input} + ] + turn = 0; handler.max_turns = max_turns + _hook('agent_before', locals()) + while turn < handler.max_turns: + turn += 1; turnstr = f'LLM Running (Turn {turn}) ...' + if handler.parent.task_dir: turnstr = f'Turn {turn} ...' + if verbose: turnstr = f'**{turnstr}**' + if yield_info: yield {'turn': turn} + yield f"\n{turnstr}\n\n" + if turn%10 == 0: client.last_tools = '' # 每10轮重置一次工具描述 + _hook('turn_before', locals()) + _hook('llm_before', locals()) + response_gen = client.chat(messages=messages, tools=tools_schema) + if verbose: + response = yield from response_gen + yield '\n\n' + else: + response = exhaust(response_gen) + cleaned = _clean_content(response.content) + if cleaned: yield cleaned + '\n' + _hook('llm_after', locals()) + + if not response.tool_calls: tool_calls = [{'tool_name': 'no_tool', 'args': {}}] + else: tool_calls = [{'tool_name': tc.function.name, 'args': json.loads(tc.function.arguments), 'id': tc.id} + for tc in response.tool_calls] + + tool_results = []; next_prompts = set(); exit_reason = {} + for ii, tc in enumerate(tool_calls): + tool_name, args, tid = tc['tool_name'], tc['args'], tc.get('id', '') + if tool_name == 'no_tool': pass + else: + if verbose: yield f"🛠️ Tool: `{tool_name}` 📥 args:\n````text\n{get_pretty_json(args)}\n````\n" + else: yield f"🛠️ {tool_name}({_compact_tool_args(tool_name, args)})\n" + handler.current_turn = turn + gen = handler.dispatch(tool_name, args, response, index=ii, tool_num=len(tool_calls)) + try: + v = next(gen) + def proxy(): yield v; return (yield from gen) + if verbose: yield '`````\n' + outcome = (yield from proxy()) if verbose else exhaust(proxy()) + if verbose: yield '`````\n' + except StopIteration as e: outcome = e.value + + if outcome.should_exit: + exit_reason = {'result': 'EXITED', 'data': outcome.data}; break + if not outcome.next_prompt: + exit_reason = {'result': 'CURRENT_TASK_DONE', 'data': outcome.data}; break + if outcome.next_prompt.startswith('未知工具'): client.last_tools = '' + if outcome.data is not None and tool_name != 'no_tool': + datastr = json.dumps(outcome.data, ensure_ascii=False, default=json_default) if type(outcome.data) in [dict, list] else str(outcome.data) + tool_results.append({'tool_use_id': tid, 'content': datastr}) + next_prompts.add(outcome.next_prompt) + if len(next_prompts) == 0 or exit_reason: + if len(handler._done_hooks) == 0 or exit_reason.get('result', '') == 'EXITED': break + next_prompts.add(handler._done_hooks.pop(0)) + next_prompt = handler.turn_end_callback(response, tool_calls, tool_results, turn, '\n'.join(next_prompts), exit_reason) + _hook('turn_after', locals()) + messages = [{"role": "user", "content": next_prompt, "tool_results": tool_results}] # just new message, history is kept in *Session + if exit_reason: handler.turn_end_callback(response, tool_calls, tool_results, turn, '', exit_reason) + _hook('agent_after', locals()) + return exit_reason or {'result': 'MAX_TURNS_EXCEEDED'} + +def _clean_content(text): + if not text: return '' + def _shrink_code(m): + lines = m.group(0).split('\n') + lang = lines[0].replace('```','').strip() + body = [l for l in lines[1:-1] if l.strip()] + if len(body) <= 6: return m.group(0) + preview = '\n'.join(body[:5]) + return f'```{lang}\n{preview}\n ... ({len(body)} lines)\n```' + text = re.sub(r'```[\s\S]*?```', _shrink_code, text) + for p in [r'[\s\S]*?', r'[\s\S]*?', r'(\r?\n){3,}']: + text = re.sub(p, '\n\n' if '\\n' in p else '', text) + return text.strip() + +def _compact_tool_args(name, args): + a = {k: v for k, v in args.items() if k != '_index'} + for k in ('path',): + if k in a: a[k] = os.path.basename(a[k]) + if name == 'update_working_checkpoint': s = a.get('key_info', ''); return (s[:60]+'...') if len(s)>60 else s + if name == 'ask_user': + q = str(a.get('question', '')) + cs = a.get('candidates') or [] + if cs: q += '\ncandidates:\n' + '\n'.join(f'- {c}' for c in cs) + return q + s = json.dumps(a, ensure_ascii=False); return (s[:120]+'...') if len(s)>120 else s diff --git a/agentmain.py b/agentmain.py new file mode 100644 index 0000000..be75146 --- /dev/null +++ b/agentmain.py @@ -0,0 +1,323 @@ +import os, sys, threading, queue, time, json, re, random, locale, glob +os.environ.setdefault('GA_LANG', 'zh' if any(k in (locale.getlocale()[0] or '').lower() for k in ('zh', 'chinese')) else 'en') +if sys.stdout is None: sys.stdout = open(os.devnull, "w") +elif hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(errors='replace') +if sys.stderr is None: sys.stderr = open(os.devnull, "w") +elif hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(errors='replace') +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from llmcore import reload_mykeys, ToolClient, MixinSession, NativeToolClient, NativeClaudeSession, NativeOAISession, resolve_client +from agent_loop import agent_runner_loop +try: + from plugins.hooks import discover_and_load; discover_and_load() +except Exception: pass +from ga import GenericAgentHandler, smart_format, get_global_memory, format_error, consume_file + +script_dir = os.path.dirname(os.path.abspath(__file__)) +BANNED_TOOLS = (['ask_user', 'start_long_term_update'] if '--no-user-tools' in sys.argv else []) +def load_tool_schema(suffix=''): + global TOOLS_SCHEMA + TS = open(os.path.join(script_dir, f'assets/tools_schema{suffix}.json'), 'r', encoding='utf-8').read() + TOOLS_SCHEMA = json.loads(TS if os.name == 'nt' else TS.replace('powershell', 'bash')) + TOOLS_SCHEMA = [t for t in TOOLS_SCHEMA if t.get('function', {}).get('name') not in BANNED_TOOLS] +load_tool_schema() + +lang_suffix = '_en' if os.environ.get('GA_LANG', '') == 'en' else '' +mem_dir = os.path.join(script_dir, 'memory') +if not os.path.exists(mem_dir): os.makedirs(mem_dir) +mem_txt = os.path.join(mem_dir, 'global_mem.txt') +if not os.path.exists(mem_txt): open(mem_txt, 'w', encoding='utf-8').write('# [Global Memory - L2]\n') +mem_insight = os.path.join(mem_dir, 'global_mem_insight.txt') +if not os.path.exists(mem_insight): + t = os.path.join(script_dir, f'assets/global_mem_insight_template{lang_suffix}.txt') + open(mem_insight, 'w', encoding='utf-8').write(open(t, encoding='utf-8').read() if os.path.exists(t) else '') +cdp_cfg = os.path.join(script_dir, 'assets/tmwd_cdp_bridge/config.js') +if not os.path.exists(cdp_cfg): + try: + os.makedirs(os.path.dirname(cdp_cfg), exist_ok=True) + open(cdp_cfg, 'w', encoding='utf-8').write(f"const TID = '__ljq_{hex(random.randint(0, 99999999))[2:8]}';") + except Exception as e: print(f'[WARN] CDP config init failed: {e} — advanced web features (tmwebdriver) will be unavailable.') + +def get_system_prompt(): + with open(os.path.join(script_dir, f'assets/sys_prompt{lang_suffix}.txt'), 'r', encoding='utf-8') as f: prompt = f.read() + prompt += f"\nToday: {time.strftime('%Y-%m-%d %a')}\n" + prompt += get_global_memory() + return prompt + +# SDK: +# agent = GenericAgent(); threading.Thread(target=agent.run, daemon=True).start() +# output1_queue = agent.put_task(prompt1) +# output2_queue = agent.put_task(prompt2) +class GenericAgent: + def __init__(self): + os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True) + self.lock = threading.Lock() + self.task_dir = None + self.history = []; self.handler = None; + self.task_queue = queue.Queue() + self.is_running = False; self.stop_sig = False; self.llm_no = 0; + self.inc_out = False; self.verbose = True + self.peer_hint = True + self.force_non_stream = False + logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}' + self.log_path = os.path.join(script_dir, f'temp/model_responses/model_responses_{logid}.txt') + self.load_llm_sessions() + self.extra_sys_prompts = [] + self.intervene = self.extrakeyinfo = None + + def load_llm_sessions(self): + mykeys, changed = reload_mykeys() + if not changed and hasattr(self, 'llmclients'): return + try: oldhistory = self.llmclient.backend.history + except: oldhistory = None + llm_sessions = [] + for k, cfg in mykeys.items(): + if not any(x in k for x in ['api', 'config', 'cookie']): continue + try: + if 'mixin' in k: llm_sessions += [{'mixin_cfg': cfg}] + elif c := resolve_client(k): llm_sessions += [c] + except: pass + for i, s in enumerate(llm_sessions): + if isinstance(s, dict) and 'mixin_cfg' in s: + try: + mixin = MixinSession(llm_sessions, s['mixin_cfg']) + if isinstance(mixin._sessions[0], (NativeClaudeSession, NativeOAISession)): llm_sessions[i] = NativeToolClient(mixin) + else: llm_sessions[i] = ToolClient(mixin) + except Exception as e: print(f'\n\n\n[ERROR] Failed to init MixinSession with cfg {s["mixin_cfg"]}: {e}!!!\n\n') + self.llmclients = llm_sessions + self.llmclient = self.llmclients[self.llm_no%len(self.llmclients)] + if oldhistory: self.llmclient.backend.history = oldhistory + + def next_llm(self, n=-1): + self.load_llm_sessions() + self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclients) + lastc = self.llmclient + self.llmclient = self.llmclients[self.llm_no] + try: self.llmclient.backend.history = lastc.backend.history + except: raise Exception('[ERROR] BAD Mixin config: Check your mykey.py') + self.llmclient.last_tools = '' + name = self.get_llm_name(model=True) + if 'glm' in name or 'minimax' in name or 'kimi' in name: load_tool_schema('_cn') + else: load_tool_schema() + def list_llms(self): + self.load_llm_sessions() + return [(i, self.get_llm_name(b), i == self.llm_no) for i, b in enumerate(self.llmclients)] + def get_llm_name(self, b=None, model=False): + b = self.llmclient if b is None else b + if isinstance(b, dict): return 'BADCONFIG_MIXIN' + if model: return b.backend.model.lower() + return f"{type(b.backend).__name__}/{b.backend.name}" + + def abort(self): + if not self.is_running: return + print('Abort current task...') + self.stop_sig = True + if self.handler is not None: self.handler.code_stop_signal.append(1) + + def put_task(self, query, source="user", images=None): + display_queue = queue.Queue() + self.task_queue.put({"query": query, "source": source, "images": images or [], "output": display_queue}) + return display_queue + + # i know it is dangerous, but raw_query is dangerous enough it doesn't enlarge + def _handle_slash_cmd(self, raw_query, display_queue): + if not raw_query.startswith('/'): return raw_query + if _sm := re.match(r'/session\.(\w+)=(.*)', raw_query.strip()): + k, v = _sm.group(1), _sm.group(2) + vfile = os.path.join(script_dir, 'temp', v) + if os.path.isfile(vfile): v = open(vfile, encoding='utf-8').read().strip() + try: v = json.loads(v) # cover number parsing + except (json.JSONDecodeError, ValueError): pass + setattr(self.llmclient.backend, k, v) + display_queue.put({'done': smart_format(f"✅ session.{k} = {repr(v)}", max_str_len=500), 'source': 'system'}) + return None + if raw_query.strip() == '/resume': + return r'帮我看看最近有哪些会话可以恢复。读model_responses/目录,按修改时间取最近10个文件,从每个文件里找最后一个...块,用一句话总结每个会话在聊什么,列表给我选。注意读文件后要把字面的\n替换成真换行才能正确匹配。' + return raw_query + + def run(self): + while True: + task = self.task_queue.get() + if isinstance(task, str): break + raw_query, source, display_queue = task["query"], task["source"], task["output"] + raw_query = self._handle_slash_cmd(raw_query, display_queue) + if raw_query is None: + self.task_queue.task_done(); continue + self.is_running = True + if len(raw_query) > 2000: + task_file = os.path.join(script_dir, 'temp', f'user_prompt_{int(time.time())}.md') + with open(task_file, 'w', encoding='utf-8') as f: f.write(raw_query) + raw_query = f'Long user prompt saved to {task_file}. Read and execute.' + rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200) + self.history.append(f"[USER]: {rquery}") + sys_prompt = get_system_prompt() + '\n'.join(self.extra_sys_prompts) + getattr(self.llmclient.backend, 'extra_sys_prompt', '') + if self.peer_hint: sys_prompt += f"\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\n" + handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp')) + if getattr(self, 'no_print', False): handler.print = lambda *a, **k: None + if self.handler and 'key_info' in self.handler.working: + ki = re.sub(r'\n\[SYSTEM\] 此为.*?工作记忆[。\n]*', '', self.handler.working['key_info']) # 去旧 + handler.working['key_info'] = ki + handler.working['passed_sessions'] = ps = self.handler.working.get('passed_sessions', 0) + 1 + if ps > 0: handler.working['key_info'] += f'\n[SYSTEM] 此为 {ps} 个对话前设置的key_info,若已在新任务,先更新或清除工作记忆。\n' + self.handler = handler # although new handler, the **full** history is in llmclient, so it is full history! + self.llmclient.log_path = self.log_path + if self.force_non_stream: + self.llmclient.backend.stream = False + self.llmclient.backend.read_timeout = max(self.llmclient.backend.read_timeout, 1200) + gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query, handler, TOOLS_SCHEMA, + max_turns=180, verbose=self.verbose, yield_info=True) + try: + full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = [] + for chunk in gen: + if consume_file(self.task_dir, '_stop'): self.abort() + if self.stop_sig: break + if isinstance(chunk, dict) and 'turn' in chunk: + curr_turn = chunk['turn']; turn_resps.append(''); continue + full_resp += chunk; turn_resps[-1] += chunk + if len(full_resp) - last_pos > 30 or 'LLM Running' in chunk: + display_queue.put({'next': full_resp[last_pos:] if self.inc_out else full_resp, + 'source': source, 'turn': curr_turn, 'outputs': turn_resps[-2:]}) + last_pos = len(full_resp) + if self.inc_out and last_pos < len(full_resp): + display_queue.put({'next': full_resp[last_pos:], 'source': source, + 'turn': curr_turn, 'outputs': turn_resps[-2:]}) + display_queue.put({'done': full_resp, 'source': source, 'turn': curr_turn, 'outputs': turn_resps.copy()}) + self.history = handler.history_info + except Exception as e: + print(f"Backend Error: {format_error(e)}") + display_queue.put({'done': full_resp + f'\n```\n{format_error(e)}\n```', 'source': source, 'turn': curr_turn, 'outputs': turn_resps.copy()}) + finally: + if self.stop_sig: print('User aborted the task.') + self.is_running = self.stop_sig = False + self.task_queue.task_done() + if self.handler is not None: self.handler.code_stop_signal.append(1) + +GeneraticAgent = GenericAgent + +if __name__ == '__main__': + import argparse + from datetime import datetime + parser = argparse.ArgumentParser() + parser.add_argument('--task', metavar='IODIR', help='一次性任务模式,先看subagent.md') + parser.add_argument('--func', metavar='PROMPT_FILE', help='纯函数模式:读prompt文件→结果写prompt.out.txt→退出') + parser.add_argument('--reflect', metavar='SCRIPT', help='反射模式:加载监控脚本,check()触发时发任务') + parser.add_argument('--input', help='prompt') + parser.add_argument('--history', help='history json file') + parser.add_argument('--llm_no', type=int, default=0) + parser.add_argument('--verbose', action='store_true') + parser.add_argument('--nobg', action='store_true') + parser.add_argument('--nolog', action='store_true') + parser.add_argument('--no-user-tools', action='store_true') + args, _unknown = parser.parse_known_args() + _extra_args = dict(zip([k.lstrip('-') for k in _unknown[::2]], _unknown[1::2])) if _unknown else {} + + if (args.func or args.task) and not args.nobg: + import subprocess, platform + cmd = [sys.executable, os.path.abspath(__file__)] + [a for a in sys.argv[1:]] + ['--nobg'] + if args.task: + d = os.path.join(script_dir, f'temp/{args.task}'); os.makedirs(d, exist_ok=True) + out = open(os.path.join(d, 'stdout.log'), 'w', encoding='utf-8') + err = open(os.path.join(d, 'stderr.log'), 'w', encoding='utf-8') + else: out, err = subprocess.DEVNULL, subprocess.DEVNULL + p = subprocess.Popen(cmd, cwd=script_dir, + creationflags=0x08000000 if platform.system() == 'Windows' else 0, + stdout=out, stderr=err) + print('PID:', p.pid); sys.exit(0) + + agent = GenericAgent() + if args.nolog: agent.log_path = False + agent.next_llm(args.llm_no) + agent.verbose = args.verbose + threading.Thread(target=agent.run, daemon=True).start() + + histfile = args.history + if args.task: + agent.task_dir = d = os.path.join(script_dir, f'temp/{args.task}'); nround = '' + infile = os.path.join(d, 'input.txt'); outfile = f'{d}/output{nround}.txt' + if args.input: + os.makedirs(d, exist_ok=True) + [os.remove(f) for f in glob.glob(os.path.join(d, 'output*.txt'))] + with open(infile, 'w', encoding='utf-8') as f: f.write(args.input) + histfile = histfile or os.path.join(d, '_history.json') + elif args.func: + infile = args.func; outfile = os.path.splitext(args.func)[0] + '.out.txt' + + if histfile and os.path.isfile(histfile): agent.llmclient.backend.history = json.loads(open(histfile, encoding='utf-8').read()) + + if args.func or args.task: + agent.peer_hint = False + with open(infile, encoding='utf-8') as f: raw = f.read() + while True: + dq = agent.put_task(raw, source='func' if args.func else 'task') + while 'done' not in (item := dq.get(timeout=2200)): + if 'next' in item: + with open(outfile, 'w', encoding='utf-8') as f: f.write(item.get('next', '')) + with open(outfile, 'w', encoding='utf-8') as f: f.write(item['done'] + '\n\n[ROUND END]\n') + if not args.task: break + consume_file(d, '_stop') # 已经成功停下来了,避免打断下次reply + for _ in range(300): # 等reply.txt,10分钟超时 + time.sleep(2) + if (raw := consume_file(d, 'reply.txt')): break + else: break + nround = nround + 1 if isinstance(nround, int) else 1 + outfile = f'{d}/output{nround}.txt' + elif args.reflect: + agent.peer_hint = False + import importlib.util + spec = importlib.util.spec_from_file_location('reflect_script', args.reflect) + mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) + if hasattr(mod, 'init'): mod.init(_extra_args) + _mt = os.path.getmtime(args.reflect) + print(f'[Reflect] loaded {args.reflect}' + (f' args={_extra_args}' if _extra_args else '')) + while True: + if os.path.getmtime(args.reflect) != _mt: + try: + spec.loader.exec_module(mod); _mt = os.path.getmtime(args.reflect) + if hasattr(mod, 'init'): mod.init(_extra_args) + print('[Reflect] reloaded') + except Exception as e: print(f'[Reflect] reload error: {e}') + try: task = mod.check() + except Exception as e: + print(f'[Reflect] check() error: {e}'); task = None + if task and task == '/exit': break + if task: + print(f'[Reflect] triggered: {task[:80]}') + dq = agent.put_task(task, source='reflect') + try: + while 'done' not in (item := dq.get(timeout=2200)): pass + result = item['done'] + print(result) + except Exception as e: + if getattr(mod, 'ONCE', False): raise + print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}' + log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True) + script_name = os.path.splitext(os.path.basename(args.reflect))[0] + open(os.path.join(log_dir, f'{script_name}_{datetime.now():%Y-%m-%d}.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\n{result}\n\n') + if (on_done := getattr(mod, 'on_done', None)): + try: on_done(result) + except Exception as e: print(f'[Reflect] on_done error: {e}') + if getattr(mod, 'ONCE', False): print('[Reflect] ONCE=True, exiting.'); break + time.sleep(getattr(mod, 'INTERVAL', 5)) + else: + try: import readline + except Exception: pass + agent.inc_out = True + if sys.stdout.isatty(): + try: model = agent.get_llm_name(model=True) or '?' + except Exception: model = '?' + try: + sys.stdout.write(f'\x1b[92m✦\x1b[0m \x1b[1mGenericAgent\x1b[0m ' + f'\x1b[90m· cli · model:\x1b[0m {model}\n') + sys.stdout.flush() + except Exception: pass + while True: + q = input('> ').strip() + if not q: continue + try: + dq = agent.put_task(q, source='user') + while True: + item = dq.get() + if 'next' in item: print(item['next'], end='', flush=True) + if 'done' in item: print(); break + except KeyboardInterrupt: + agent.abort(); print('\n[Interrupted]') diff --git a/assets/GenericAgent_Technical_Report.pdf b/assets/GenericAgent_Technical_Report.pdf new file mode 100644 index 0000000..cd5d59b Binary files /dev/null and b/assets/GenericAgent_Technical_Report.pdf differ diff --git a/assets/agent_bbs.py b/assets/agent_bbs.py new file mode 100644 index 0000000..7c8dd35 --- /dev/null +++ b/assets/agent_bbs.py @@ -0,0 +1,225 @@ +# agent_bbs.py — 极简Agent公告板(多板块版) +# 启动: uvicorn agent_bbs:app --host 0.0.0.0 --port 58800 +# 或: python agent_bbs.py + +import sqlite3, uuid, time, json, os +from threading import Lock, Thread +from fastapi import FastAPI, HTTPException, Query, Body, UploadFile, File +from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse, FileResponse +from contextlib import contextmanager +from starlette.requests import Request +from starlette.responses import Response +from starlette.middleware.base import BaseHTTPMiddleware + +# key → board config; 修改 boards.json 可热重载新增板块 +BOARDS_FILE = "boards.json" +DEFAULT_BOARDS = {"agent-bbs-test": {"name": "default", "db": "agent_bbs.db"}} +BOARDS, BOARDS_MTIME_NS, BOARDS_LOCK = DEFAULT_BOARDS, None, Lock() +_T=[time.time()] + +def load_boards_if_changed(): + global BOARDS, BOARDS_MTIME_NS + with BOARDS_LOCK: + if BOARDS_FILE is None: + if BOARDS_MTIME_NS is None: init_db(); BOARDS_MTIME_NS = 0 + return BOARDS + if not os.path.exists(BOARDS_FILE): + json.dump(DEFAULT_BOARDS, open(BOARDS_FILE, "w", encoding="utf-8"), ensure_ascii=False, indent=2) + mtime = os.stat(BOARDS_FILE).st_mtime_ns + if mtime == BOARDS_MTIME_NS: return BOARDS + try: + new = json.load(open(BOARDS_FILE, "r", encoding="utf-8")) + assert isinstance(new, dict) and all(isinstance(v, dict) and "db" in v and "name" in v for v in new.values()) + BOARDS, BOARDS_MTIME_NS = new, mtime; init_db() + print(f"[boards] reloaded {len(BOARDS)} boards") + except Exception as e: print(f"[boards] reload failed, keep old config: {e}") + return BOARDS + +UPLOAD_DIR = "bbs_files" + +app = FastAPI(title="Agent BBS", docs_url=None, redoc_url=None, openapi_url=None) + +class ApiKeyMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + key = request.headers.get("x-api-key") or request.query_params.get("key") + board = load_boards_if_changed().get(key) + if not board: return Response("Not Found", status_code=404) + request.state.board = board + return await call_next(request) + +app.add_middleware(ApiKeyMiddleware) + +HTML_PAGE = """ +Agent BBS + +

Agent BBS

+
+ + + + +
+
+""" + +README_TEXT = "Agent BBS API\tAuth: ALL requests require header X-API-Key: or pass ?key= as query parameter.\t1. Register: POST /register body: {\"name\": \"your-agent-name\"}\tResponse: {\"token\": \"xxx\", \"name\": \"your-agent-name\"}\t2. Post: POST /post body: {\"token\": \"xxx\", \"content\": \"your message\"}\tResponse: {\"id\": 1, \"author\": \"your-agent-name\"}\t3. Poll new: GET /poll?since_id=0&limit=50\tReturns posts with id > since_id, ordered by id asc. Keep track of the last id you received, use it as since_id next time.\t4. Query: GET /posts?author=xxx&limit=50\tauthor is optional. Returns posts ordered by id desc. 5. Upload file: POST /file/upload multipart/form-data, form fields: token (your agent token) + file (the file). Requires X-API-Key. Response: {\"ref\": \"a1b2c3/filename.ext\"}. Paste ref into post content to reference the file. 6. Download file: GET /file/{rand_id}/{filename} Requires X-API-Key. e.g. /file/a1b2c3/filename.ext" + +@app.get("/readme") +def readme(): return PlainTextResponse(README_TEXT) + +@app.get("/", response_class=HTMLResponse) +def index(): return HTML_PAGE + +@contextmanager +def get_db(db_path): + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: conn.close() + +def _db(request): return request.state.board["db"] + +def init_db(): + for board in BOARDS.values(): + with get_db(board["db"]) as db: + db.execute("""CREATE TABLE IF NOT EXISTS users ( + token TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, created_at REAL)""") + db.execute("""CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, + content TEXT NOT NULL, created_at REAL, + FOREIGN KEY(author) REFERENCES users(name))""") + db.execute("CREATE INDEX IF NOT EXISTS idx_posts_id ON posts(id)") + +def verify_token(token, db_path): + with get_db(db_path) as db: + row = db.execute("SELECT name FROM users WHERE token=?", (token,)).fetchone() + if not row: raise HTTPException(401, "invalid token") + return row["name"] + +@app.on_event("startup") +def startup(): + os.makedirs(UPLOAD_DIR, exist_ok=True) + load_boards_if_changed() + +@app.post("/register") +def register(request: Request, name=Body(..., embed=True)): + token = uuid.uuid4().hex[:16] + try: + with get_db(_db(request)) as db: + db.execute("INSERT INTO users VALUES(?,?,?)", (token, name, time.time())) + except sqlite3.IntegrityError: + with get_db(_db(request)) as db: + row = db.execute("SELECT token FROM users WHERE name=?", (name,)).fetchone() + return {"token": row["token"], "name": name} + return {"token": token, "name": name} + +@app.post("/post") +def create_post(request: Request, token=Body(...), content=Body(...)): + author = verify_token(token, _db(request)) + with get_db(_db(request)) as db: + cur = db.execute("INSERT INTO posts(author,content,created_at) VALUES(?,?,?)", + (author, content, time.time())) + post_id = cur.lastrowid + _T[0]=time.time() + return {"id": post_id, "author": author} + +@app.get("/poll") +def poll(request: Request, since_id=Query(0), limit=Query(50)): + with get_db(_db(request)) as db: + rows = db.execute("SELECT id,author,content,created_at FROM posts WHERE id>? ORDER BY id LIMIT ?", + (since_id, limit)).fetchall() + return [dict(r) for r in rows] + +@app.get("/count") +def count_posts(request: Request, author=Query(None)): + with get_db(_db(request)) as db: + q, p = ("SELECT COUNT(*) c FROM posts WHERE author=?", (author,)) if author else ("SELECT COUNT(*) c FROM posts", ()) + return {"total": db.execute(q, p).fetchone()["c"]} + +@app.get("/authors") +def get_authors(request: Request): + with get_db(_db(request)) as db: + return [r["author"] for r in db.execute("SELECT DISTINCT author FROM posts ORDER BY author").fetchall()] + +@app.get("/posts") +def get_posts(request: Request, author=Query(None), limit=Query(50), offset=Query(0)): + with get_db(_db(request)) as db: + if author: + rows = db.execute("SELECT id,author,content,created_at FROM posts WHERE author=? ORDER BY id DESC LIMIT ? OFFSET ?", + (author, limit, offset)).fetchall() + else: + rows = db.execute("SELECT id,author,content,created_at FROM posts ORDER BY id DESC LIMIT ? OFFSET ?", + (limit, offset)).fetchall() + return [dict(r) for r in rows] + +@app.post("/file/upload") +def upload_file(request: Request, token=Body(...), file: UploadFile = File(...)): + verify_token(token, _db(request)) + rand_id = uuid.uuid4().hex[:6] + safe_name = os.path.basename(file.filename) + dest = os.path.join(UPLOAD_DIR, rand_id) + os.makedirs(dest, exist_ok=True) + with open(os.path.join(dest, safe_name), "wb") as f: + f.write(file.file.read()) + return {"ref": f"{rand_id}/{safe_name}"} + +@app.get("/file/{rand_id}/{filename}") +def download_file(rand_id: str, filename: str): + path = os.path.join(UPLOAD_DIR, rand_id, os.path.basename(filename)) + if not os.path.exists(path): + raise HTTPException(404, "not found") + return FileResponse(path, filename=filename) + +if __name__ == "__main__": + import argparse, uvicorn + p = argparse.ArgumentParser(); p.add_argument("--cwd"); p.add_argument("--port", type=int, default=58800); p.add_argument("--key") + a = p.parse_args(); + if a.cwd: os.chdir(a.cwd) + if a.key: BOARDS_FILE = None; BOARDS.clear(); BOARDS[a.key] = {"name": "default", "db": f"{a.key}.db"}; Thread(target=lambda:[time.sleep(3600) or time.time()-_T[0]>172800 and os._exit(0) for _ in iter(int,1)],daemon=True).start() + uvicorn.run(app, host="0.0.0.0", port=a.port) \ No newline at end of file diff --git a/assets/code_run_header.py b/assets/code_run_header.py new file mode 100644 index 0000000..59fb341 --- /dev/null +++ b/assets/code_run_header.py @@ -0,0 +1,27 @@ +import sys, os, json, re, time, subprocess +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'memory')) +_r = subprocess.run +def _d(b): + if not b: return '' + if isinstance(b, str): return b + try: return b.decode() + except: return b.decode('gbk', 'replace') +def _run(*a, **k): + t = k.pop('text', 0) | k.pop('universal_newlines', 0) + enc = k.pop('encoding', None) + k.pop('errors', None) + if enc: t = 1 + if t and isinstance(k.get('input'), str): + k['input'] = k['input'].encode() + r = _r(*a, **k) + if t: + if r.stdout is not None: r.stdout = _d(r.stdout) + if r.stderr is not None: r.stderr = _d(r.stderr) + return r +subprocess.run = _run +_Pi = subprocess.Popen.__init__ +def _pinit(self, *a, **k): + if os.name == 'nt': k['creationflags'] = (k.get('creationflags') or 0) | 0x08000000 + _Pi(self, *a, **k) +subprocess.Popen.__init__ = _pinit +sys.excepthook = lambda t, v, tb: (sys.__excepthook__(t, v, tb), print(f"\n[Agent Hint]: NO GUESSING! You MUST probe first. If missing common package, pip.")) if issubclass(t, (ImportError, AttributeError)) else sys.__excepthook__(t, v, tb) diff --git a/assets/configure_mykey.py b/assets/configure_mykey.py new file mode 100644 index 0000000..b9ed19d --- /dev/null +++ b/assets/configure_mykey.py @@ -0,0 +1,1430 @@ +#!/usr/bin/env python3 +""" +GenericAgent — 交互式初始化向导 (configure.py) +一键配置 LLM 模型 + 消息平台,自动生成 mykey.py + +用法: + python configure.py +""" + +import ast +import os +import sys +import re +import shutil +import json +import urllib.request +from datetime import datetime + +# ── ANSI 颜色 ────────────────────────────────────────────────────────────── +C = { + 'reset': '\033[0m', 'bold': '\033[1m', 'dim': '\033[2m', + 'red': '\033[91m', 'green': '\033[92m', 'yellow': '\033[93m', + 'blue': '\033[94m', 'magenta': '\033[95m', 'cyan': '\033[96m', 'white': '\033[97m', +} + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MYKPY_PATH = os.path.join(PROJECT_ROOT, 'mykey.py') + +# ── 模型厂商定义 ─────────────────────────────────────────────────────────── + +LLM_PROVIDERS = [ + # ═══════════════════════════ 通用协议(官方直连或任意兼容中转)═══════════════════════════ + { + 'id': 'oai_chat', + 'name': 'OpenAI Chat Completions 协议', + 'desc': '官方直连或任意 OAI 兼容中转/网关,自填 apibase(回车=OpenAI 官方)', + 'type': 'native_oai', + 'template': { + 'name': 'gpt-native', 'apikey': 'sk-', + 'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.5', + 'api_mode': 'chat_completions', 'reasoning_effort': 'high', + 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120, + }, + 'key_hint': '官方在 https://platform.openai.com/api-keys 获取;中转站填其提供的 Key', + 'model_choices': ['gpt-5.5', 'gpt-5.4'], + 'extra_fields': [ + {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.openai.com/v1'}, + ], + }, + { + 'id': 'oai_responses', + 'name': 'OpenAI Responses 协议', + 'desc': 'Responses API(o 系列/GPT-5.5 推荐端点),官方或兼容网关,自填 apibase', + 'type': 'native_oai', + 'template': { + 'name': 'gpt-responses', 'apikey': 'sk-', + 'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.5', + 'api_mode': 'responses', 'reasoning_effort': 'high', + 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120, + }, + 'key_hint': '官方在 https://platform.openai.com/api-keys 获取;中转站填其提供的 Key', + 'model_choices': ['gpt-5.5', 'gpt-5.4'], + 'extra_fields': [ + {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.openai.com/v1'}, + ], + }, + { + 'id': 'claude_messages', + 'name': 'Claude Messages 协议', + 'desc': 'Anthropic 官方直连或任意 Claude 兼容中转,自填 apibase(回车=官方)', + 'type': 'native_claude', + 'template': { + 'name': 'anthropic-direct', 'apikey': 'sk-ant-', + 'apibase': 'https://api.anthropic.com', 'model': 'claude-opus-4-7', + 'thinking_type': 'adaptive', 'max_tokens': 32768, 'temperature': 1, + }, + 'key_hint': '官方在 https://console.anthropic.com/ 获取;中转站填其提供的 Key', + 'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'], + 'extra_fields': [ + {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.anthropic.com'}, + ], + }, + # ═══════════════════════════ 直连 API(按旗舰能力降序)═══════════════════════════ + { + 'id': 'deepseek', + 'name': 'DeepSeek (v4-Pro / Flash)', + 'desc': '开源模型,v4-Pro 旗舰 1M 上下文', + 'type': 'native_oai', + 'template': { + 'name': 'deepseek', 'apikey': 'sk-', + 'apibase': 'https://api.deepseek.com', 'model': 'deepseek-v4-pro', + 'api_mode': 'chat_completions', 'reasoning_effort': 'high', + }, + 'key_hint': '在 https://platform.deepseek.com/api_keys 获取', + 'model_choices': ['deepseek-v4-pro', 'deepseek-v4-flash'], + }, + { + 'id': 'kimi', + 'name': 'Kimi (k2.6 / k2.5) 双协议', + 'desc': '月之暗面,支持 Anthropic 和 OAI 双协议', + 'type': 'native_claude', + 'template': { + 'name': 'kimi', 'apikey': 'sk-kimi-', + 'apibase': 'https://api.kimi.com/coding', + 'model': 'kimi-for-coding', 'fake_cc_system_prompt': True, + 'thinking_type': 'adaptive', + }, + 'key_hint': '在 https://kimi.com/code 或 https://platform.moonshot.cn/ 获取', + 'model_choices': ['kimi-k2.6', 'kimi-k2.5'], + 'extra_fields': [ + { + 'key': '_protocol', 'label': '选择 API 协议', + 'type': 'choice', + 'options': [ + {'id': 'native_claude', 'name': 'Anthropic 兼容 (推荐)', 'desc': 'kimi-for-coding 端点,CC 兼容', 'apibase': 'https://api.kimi.com/coding', 'fake_cc_system_prompt': True, 'model': 'kimi-for-coding'}, + {'id': 'native_oai', 'name': 'OpenAI 协议', 'desc': 'Moonshot OAI 端点,kimi-k2 系列', 'apibase': 'https://api.moonshot.cn/v1', 'model': 'kimi-k2.6'}, + ], + }, + ], + }, + { + 'id': 'qwen', + 'name': '阿里通义千问 (Qwen3.5 / 百炼)', + 'desc': '阿里云百炼,Qwen3 系列百万级上下文', + 'type': 'native_oai', + 'template': { + 'name': 'qwen', 'apikey': 'sk-', + 'apibase': 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'model': 'qwen3.6-max-preview', + 'api_mode': 'chat_completions', + }, + 'key_hint': '在 https://bailian.console.aliyun.com/ 获取 API Key', + 'model_choices': ['qwen3.6-max-preview', 'qwen3.5-plus', 'qwen3-coder-plus'], + 'extra_fields': [ + { + 'key': '_endpoint', 'label': '选择端点', + 'type': 'choice', + 'options': [ + {'id': 'standard', 'name': '标准按量付费', 'desc': 'dashscope.aliyuncs.com,兼容模式', 'apibase': 'https://dashscope.aliyuncs.com/compatible-mode/v1'}, + {'id': 'coding_plan', 'name': '百炼 Coding Plan (订阅)', 'desc': 'coding-intl.dashscope.aliyuncs.com,100万上下文', 'apibase': 'https://coding-intl.dashscope.aliyuncs.com/v1', 'context_win': 1000000}, + ], + }, + ], + }, + { + 'id': 'zhipu', + 'name': '智谱 GLM-5.1 (Coding Plan)', + 'desc': '智谱 GLM,支持 Coding Plan CN (Anthropic) 和 Global (OAI) 双端点', + 'type': 'native_claude', + 'template': { + 'name': 'zhipu-glm', 'apikey': 'sk-', + 'apibase': 'https://open.bigmodel.cn/api/anthropic', + 'model': 'GLM-5.1-Cloud', 'fake_cc_system_prompt': False, + 'thinking_type': 'adaptive', 'max_retries': 3, + 'connect_timeout': 10, 'read_timeout': 180, + }, + 'key_hint': 'CN 在 https://open.bigmodel.cn/ 获取;Global 在 https://z.ai/ 获取', + 'model_choices': ['GLM-5.1-Cloud', 'glm-4.7'], + 'extra_fields': [ + { + 'key': '_plan', 'label': '选择 Coding Plan', + 'type': 'choice', + 'options': [ + {'id': 'native_claude', 'name': 'Coding Plan CN (Anthropic)', 'desc': 'open.bigmodel.cn,推荐国内用户', 'apibase': 'https://open.bigmodel.cn/api/anthropic', 'fake_cc_system_prompt': False}, + {'id': 'native_oai', 'name': 'Coding Plan Global (OAI)', 'desc': 'api.z.ai,OpenAI 协议,全球可用', 'apibase': 'https://api.z.ai/api/paas/v4'}, + ], + }, + ], + }, + { + 'id': 'minimax', + 'name': 'MiniMax M3 (双协议)', + 'desc': 'MiniMax M3,支持 Anthropic 和 OpenAI 双协议', + 'type': 'native_claude', + 'template': { + 'name': 'minimax', 'apikey': 'eyJh...', + 'apibase': 'https://api.minimaxi.com/anthropic', + 'model': 'MiniMax-M3', 'max_retries': 3, + }, + 'key_hint': '在 https://platform.minimaxi.com/user-center/basic-information 获取', + 'model_choices': ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'], + 'extra_fields': [ + { + 'key': '_protocol', 'label': '选择 API 协议', + 'type': 'choice', + 'options': [ + {'id': 'native_claude', 'name': 'Anthropic 协议 (推荐)', 'desc': '无 标签,原生 Claude 兼容', 'apibase': 'https://api.minimaxi.com/anthropic'}, + {'id': 'native_oai', 'name': 'OpenAI 协议', 'desc': '走 /v1/chat/completions', 'apibase': 'https://api.minimaxi.com/v1', 'context_win': 50000}, + ], + }, + ], + }, + { + 'id': 'stepfun', + 'name': '阶跃星辰 Step-3.5 (推理强)', + 'desc': '阶跃星辰 Step 系列,支持标准和 Step Plan 双端点', + 'type': 'native_oai', + 'template': { + 'name': 'stepfun', 'apikey': 'sk-', + 'apibase': 'https://api.stepfun.com/v1', + 'model': 'step-3.5-flash', + 'api_mode': 'chat_completions', + 'context_win': 262144, + }, + 'key_hint': '在 https://platform.stepfun.com/ 获取 API Key', + 'model_choices': ['step-3.5-flash', 'step-3.5-flash-2603'], + 'extra_fields': [ + { + 'key': '_endpoint', 'label': '选择端点', + 'type': 'choice', + 'options': [ + {'id': 'standard', 'name': '标准端点', 'desc': 'api.stepfun.com/v1,按量付费', 'apibase': 'https://api.stepfun.com/v1', 'context_win': 262144}, + {'id': 'step_plan', 'name': 'Step Plan (订阅)', 'desc': 'api.stepfun.com/step_plan/v1,订阅制', 'apibase': 'https://api.stepfun.com/step_plan/v1', 'context_win': 262144}, + ], + }, + ], + }, + { + 'id': 'qianfan', + 'name': '百度千帆 (ERNIE 5.0 / 第三方)', + 'desc': '百度智能云千帆,文心一言 ERNIE 5.0 + DeepSeek 等', + 'type': 'native_oai', + 'template': { + 'name': 'baidu-qianfan', 'apikey': '', + 'apibase': 'https://qianfan.baidubce.com/v2', + 'model': 'ernie-5.0-thinking-preview', + 'api_mode': 'chat_completions', + }, + 'key_hint': '在 https://console.bce.baidu.com/qianfan/ 创建应用获取 API Key', + 'model_choices': ['ernie-5.0-thinking-preview', 'deepseek-v3.2'], + 'extra_fields': [ + {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://qianfan.baidubce.com/v2'}, + ], + }, + { + 'id': 'volcengine', + 'name': '火山引擎 (豆包 / Ark)', + 'desc': '字节跳动火山引擎,支持标准 Ark 和 Ark Coding Plan', + 'type': 'native_oai', + 'template': { + 'name': 'volc-ark', 'apikey': '', + 'apibase': 'https://ark.cn-beijing.volces.com/api/v3', + 'model': 'doubao-seed-code-preview-251028', + 'api_mode': 'chat_completions', + }, + 'key_hint': '在 https://console.volcengine.com/ark/ 创建推理接入点后获取 API Key', + 'model_choices': ['doubao-seed-code-preview-251028', 'doubao-seed-1-8-251228'], + 'extra_fields': [ + { + 'key': '_endpoint', 'label': '选择端点', + 'type': 'choice', + 'options': [ + {'id': 'standard', 'name': '标准 Ark', 'desc': 'ark.cn-beijing.volces.com/api/v3,按量付费', 'apibase': 'https://ark.cn-beijing.volces.com/api/v3'}, + {'id': 'coding_plan', 'name': 'Ark Coding Plan (订阅)', 'desc': 'ark.cn-beijing.volces.com/api/coding/v3', 'apibase': 'https://ark.cn-beijing.volces.com/api/coding/v3'}, + ], + }, + ], + }, + { + 'id': 'xiaomi', + 'name': '小米 MiMo (MiMo 2.5 Pro / TokenPlan)', + 'desc': '小米 MiMo 系列,超大上下文窗口,支持 TokenPlan 预付费', + 'type': 'native_oai', + 'template': { + 'name': 'xiaomi-mimo', 'apikey': 'sk-', + 'apibase': 'https://api.xiaomimimo.com/v1', + 'model': 'mimo-v2.5-pro', + 'api_mode': 'chat_completions', + }, + 'key_hint': '在 https://x.xiaomi.com/ 获取 API Key', + 'model_choices': ['mimo-v2.5-pro', 'mimo-v2-flash'], + 'extra_fields': [ + {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://api.xiaomimimo.com/v1'}, + ], + }, + { + 'id': 'tencent_tokenhub', + 'name': '腾讯混元 TokenHub (Hy3 / TokenPlan)', + 'desc': '腾讯云 TokenHub,混元 Hy3 系列,TokenPlan 预付费', + 'type': 'native_oai', + 'template': { + 'name': 'tencent-tokenhub', 'apikey': 'sk-', + 'apibase': 'https://tokenhub.tencentmaas.com/v1', + 'model': 'hy3-preview', + 'api_mode': 'chat_completions', + }, + 'key_hint': '在 https://console.cloud.tencent.com/tokenhub 获取 API Key', + 'model_choices': ['hy3-preview'], + 'extra_fields': [ + {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://tokenhub.tencentmaas.com/v1'}, + ], + }, + # ═══════════════════════════ 代理 / 中继(支持 Claude/GPT 等顶级模型)══════════ + { + 'id': 'cc_relay', + 'name': 'CC Switch 透传 (社区常用)', + 'desc': '社区 Claude Code 透传渠道,可接入 Claude Opus', + 'type': 'native_claude', + 'template': { + 'name': 'cc-relay', 'apikey': 'sk-user-', + 'apibase': 'https:///claude/office', + 'model': 'claude-opus-4-7', 'fake_cc_system_prompt': True, + 'thinking_type': 'adaptive', + }, + 'key_hint': '从你的 CC Switch 服务商获取 apikey 和 apibase', + 'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'], + 'extra_fields': [ + {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://your-host/claude/office'}, + {'key': 'fake_cc_system_prompt', 'label': 'fake_cc_system_prompt', 'type': 'bool', 'default': True}, + ], + }, + { + 'id': 'openrouter', + 'name': 'OpenRouter (多模型中继)', + 'desc': '一个 Key 通吃 Claude/GPT/DeepSeek/Qwen 等', + 'type': 'native_oai', + 'template': { + 'name': 'openrouter', 'apikey': 'sk-or-', + 'apibase': 'https://openrouter.ai/api/v1', + 'model': 'anthropic/claude-opus-4-7', + 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120, + }, + 'key_hint': '在 https://openrouter.ai/keys 获取', + 'model_choices': ['anthropic/claude-opus-4-7', 'openai/gpt-5.5'], + }, + { + 'id': 'commonstack', + 'name': 'CommonStack (统一网关)', + 'desc': '一个 Key 通吃 Claude/GPT/Gemini/DeepSeek/MiniMax/Zhipu/xAI 等', + 'type': 'native_oai', + 'template': { + 'name': 'commonstack', 'apikey': 'sk-', + 'apibase': 'https://api.commonstack.ai/v1', + 'model': 'anthropic/claude-opus-4-7', + 'api_mode': 'chat_completions', + 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120, + }, + 'key_hint': '在 https://commonstack.ai 注册后从 Dashboard 获取 API Key', + 'model_choices': ['anthropic/claude-opus-4-7', 'openai/gpt-5.5'], + }, + { + 'id': 'crs', + 'name': 'CRS 反代 (Claude Max 多通道)', + 'desc': 'CRS 协议的反代服务,支持 Claude Max / Gemini Ultra 通道', + 'type': 'native_claude', + 'template': { + 'name': 'crs', 'apikey': 'cr_', + 'apibase': 'https:///api', + 'model': 'claude-opus-4-7[1m]', 'fake_cc_system_prompt': True, + 'thinking_type': 'adaptive', 'max_tokens': 32768, + 'max_retries': 3, 'read_timeout': 180, + }, + 'key_hint': '从你的 CRS 服务商获取 key 和 host', + 'model_choices': ['claude-opus-4-7[1m]', 'claude-sonnet-4-6'], + 'extra_fields': [ + { + 'key': '_channel', 'label': '选择 CRS 通道', + 'type': 'choice', + 'options': [ + {'id': 'claude_max', 'name': 'Claude Max (默认)', 'desc': '标准 CRS Claude 通道', 'apibase': 'https:///api'}, + {'id': 'gemini_ultra', 'name': 'Gemini Ultra (Antigravity)', 'desc': 'CRS 包装的 Google Antigravity,不支持 SSE 流式', 'apibase': 'https:///antigravity/api', 'model': 'claude-opus-4-7-thinking', 'stream': False}, + ], + }, + ], + }, + { + 'id': 'gmi', + 'name': 'GMI Serving (通用模型中继)', + 'desc': 'GMI 通用模型推理服务,支持多种开源/闭源(手动输入模型名)', + 'type': 'native_oai', + 'template': { + 'name': 'gmi', 'apikey': '', + 'apibase': 'https://api.gmi-serving.com/v1', + 'model': 'gmi-default', + 'api_mode': 'chat_completions', + }, + 'key_hint': '从 GMI 服务商获取 API Key,探测失败时手动输入模型名', + 'model_choices': [], # 中继服务,模型由服务商提供,探测失败时手动输入 + 'extra_fields': [ + {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://api.gmi-serving.com/v1'}, + ], + }, +] + +# ── 消息平台定义 ──────────────────────────────────────────────────────────── +PLATFORMS = [ + { + 'id': 'none', + 'name': '不使用消息平台(纯终端 REPL)', + 'desc': '直接用 python agentmain.py 在终端交互', + 'deps': [], + }, + { + 'id': 'telegram', + 'name': 'Telegram 机器人', + 'desc': '通过 Telegram Bot 与 Agent 对话', + 'file': 'frontends/tgapp.py', + 'deps': ['python-telegram-bot'], + 'env_vars': [ + {'key': 'tg_bot_token', 'label': 'Bot Token', 'hint': '从 @BotFather 获取'}, + {'key': 'tg_allowed_users', 'label': '允许的用户 ID(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True}, + ], + }, + { + 'id': 'qq', + 'name': 'QQ 机器人', + 'desc': '通过 QQ 官方机器人 API 接入', + 'file': 'frontends/qqapp.py', + 'deps': ['qq-botpy'], + 'env_vars': [ + {'key': 'qq_app_id', 'label': 'App ID', 'hint': 'QQ 开放平台获取'}, + {'key': 'qq_app_secret', 'label': 'App Secret'}, + {'key': 'qq_allowed_users', 'label': '允许的用户 OpenID(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True}, + ], + }, + { + 'id': 'feishu', + 'name': '飞书机器人', + 'desc': '通过飞书应用与 Agent 对话', + 'file': 'frontends/fsapp.py', + 'deps': ['lark-oapi'], + 'env_vars': [ + {'key': 'fs_app_id', 'label': 'App ID', 'hint': '飞书开放平台获取'}, + {'key': 'fs_app_secret', 'label': 'App Secret'}, + {'key': 'fs_allowed_users', 'label': '允许的用户(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True}, + ], + }, + { + 'id': 'wecom', + 'name': '企业微信机器人', + 'desc': '通过企业微信 Bot 接入', + 'file': 'frontends/wecomapp.py', + 'deps': ['wecombot'], + 'env_vars': [ + {'key': 'wecom_bot_id', 'label': 'Bot ID'}, + {'key': 'wecom_secret', 'label': 'Bot Secret'}, + {'key': 'wecom_allowed_users', 'label': '允许的用户(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True}, + ], + }, + { + 'id': 'dingtalk', + 'name': '钉钉机器人', + 'desc': '通过钉钉应用接入', + 'file': 'frontends/dingtalkapp.py', + 'deps': ['dingtalk-sdk'], + 'env_vars': [ + {'key': 'dingtalk_client_id', 'label': 'Client ID (App Key)'}, + {'key': 'dingtalk_client_secret', 'label': 'Client Secret (App Secret)'}, + {'key': 'dingtalk_allowed_users', 'label': '允许的用户 StaffID(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True}, + ], + }, + { + 'id': 'discord', + 'name': 'Discord 机器人', + 'desc': '通过 Discord Bot 接入', + 'file': 'frontends/dcapp.py', + 'deps': ['discord.py'], + 'env_vars': [ + {'key': 'dc_bot_token', 'label': 'Bot Token', 'hint': 'Discord Developer Portal 获取'}, + {'key': 'dc_allowed_users', 'label': '允许的用户 ID(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True}, + ], + }, + { + 'id': 'wechat', + 'name': '微信 (iLink 协议)', + 'desc': '通过微信个人号与 Agent 对话,扫码自动登录', + 'file': 'frontends/wechatapp.py', + 'deps': ['requests', 'qrcode', 'pycryptodome'], + 'env_vars': [], + }, +] + + +def _masked(v, reveal, tail): + """生成脱敏字符串:前 reveal 位明文 + * + 后 tail 位明文""" + if len(v) > reveal + tail: + return v[:reveal] + '*' * min(len(v) - reveal - tail, 8) + v[-tail:] + elif len(v) > reveal: + return v[:reveal] + '*' * (len(v) - reveal) + return v + +def masked_input(prompt, reveal=6, tail=4): + """密文输入,支持粘贴:批读取 + 延迟重绘,避免快速键入时丢字符。 + + prompt 必须为单行(不含 \\n)。 + """ + sys.stdout.write(prompt) + sys.stdout.flush() + chars = [] + + def _repaint(): + m = _masked(''.join(chars), reveal, tail) + sys.stdout.write(f'\r{prompt}{m} \r{prompt}{m}') + sys.stdout.flush() + + def _process(c): + """处理单个字符,返回 True 表示应退出。""" + if c in ('\r', '\n'): + return True + if c in ('\x03', '\x04'): + raise KeyboardInterrupt + if c in ('\x08', '\x7f'): + if chars: + chars.pop() + elif c.isprintable() or c == ' ': + chars.append(c) + return False + + if os.name == 'nt': + import msvcrt + while True: + c = msvcrt.getwch() + if _process(c): + break + if c in ('\x08', '\x7f'): + _repaint() # 退格立即重绘 + continue + if not (c.isprintable() or c == ' '): + continue + # 批量读取:粘贴时一次取完 + while msvcrt.kbhit(): + c2 = msvcrt.getwch() + if _process(c2): + value = ''.join(chars) + _repaint() + sys.stdout.write('\n') + sys.stdout.flush() + return value + _repaint() + else: + import tty, termios, select + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + while True: + c = sys.stdin.read(1) + if _process(c): + break + if c in ('\x08', '\x7f'): + _repaint() # 退格立即重绘 + continue + if not (c.isprintable() or c == ' '): + continue + # 批量读取:只要 stdin 有数据就继续读,不重绘 + while select.select([sys.stdin], [], [], 0) == ([sys.stdin], [], []): + c2 = sys.stdin.read(1) + if _process(c2): + value = ''.join(chars) + _repaint() + termios.tcsetattr(fd, termios.TCSADRAIN, old) + sys.stdout.write('\n') + sys.stdout.flush() + return value + _repaint() + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + value = ''.join(chars) + _repaint() + sys.stdout.write('\n') + sys.stdout.flush() + return value + + +# ═══════════════════════════════════════════════════════════════════════════ +# UI Helpers +# ═══════════════════════════════════════════════════════════════════════════ + +def cprint(text, color=None, bold=False, end='\n'): + parts = [] + if color: parts.append(C.get(color, '')) + if bold: parts.append(C['bold']) + parts.append(text) + parts.append(C['reset']) + print(''.join(parts), end=end) + +def banner(): + print('\033[2J\033[H', end='') # ANSI 清屏,跨平台 + print(f"{C['cyan']}{C['bold']}") + print(" ╔═══════════════════════════════════════════════════════════╗") + print(" ║ GenericAgent — 交互式初始化向导 v1.2 ║") + print(" ║ 一键配置 LLM 模型 + 消息平台,自动生成 mykey.py ║") + print(" ╚═══════════════════════════════════════════════════════════╝") + print(f"{C['reset']}") + print(f"{C['dim']} 项目目录: {PROJECT_ROOT}{C['reset']}") + print() + +def _check_python(): + """检查 Python 版本,返回 (ok, msg)""" + vi = sys.version_info + if vi < (3, 10): + return False, f"Python {vi.major}.{vi.minor} 不满足最低要求 (≥ 3.10)" + if vi[:2] == (3, 12): + return True, '' + return True, f"⚠ 当前 Python {vi.major}.{vi.minor},推荐使用 Python 3.12" + +def ask_choice(prompt, choices, allow_multi=False, default=None): + """交互式选择,返回 selected_id 或 [selected_ids]""" + print(f"\n{C['bold']}{prompt}{C['reset']}") + if allow_multi: + print(f"{C['dim']} (可多选,输入序号用逗号分隔,如: 1,3,5;输入 a 全选;回车跳过){C['reset']}") + else: + print(f"{C['dim']} (输入序号,如: 1){C['reset']}") + for i, c in enumerate(choices, 1): + desc = c.get('desc', '') + print(f" {C['green']}{i}.{C['reset']} {C['bold']}{c['name']}{C['reset']} {C['dim']}{desc}{C['reset']}") + while True: + raw = input(f"\n {C['yellow']}►{C['reset']} ").strip() + if not raw and default is not None: + return default + if allow_multi: + if raw.lower() == 'a': + return [c['id'] for c in choices] + parts = [p.strip() for p in raw.split(',') if p.strip()] + selected = [] + for p in parts: + try: + idx = int(p) - 1 + if 0 <= idx < len(choices): + selected.append(choices[idx]['id']) + except ValueError: + pass + if selected: + return selected + else: + try: + idx = int(raw) - 1 + if 0 <= idx < len(choices): + return choices[idx]['id'] + except ValueError: + pass + print(f" {C['red']}✗ 请输入有效序号{C['reset']}") + +def ask_input(prompt, default=None, secret=False, hint=None): + """交互式输入。secret=True 时使用脱敏输入。""" + if hint: + cprint(f" {hint}", 'dim') + if default is not None: + cprint(f" [默认: {default}]", 'dim') + prompt_line = f" {C['yellow']}►{C['reset']} {prompt}: " + while True: + if secret: + val = masked_input(prompt_line).strip() + else: + val = input(prompt_line).strip() + if not val and default is not None: + return default + if val: + return val + cprint("✗ 此项不能为空", 'red') + +def ask_yesno(prompt, default=True): + hint = "Y/N" + raw = input(f"\n {C['yellow']}►{C['reset']} {prompt} ({hint}): ").strip().lower() + if not raw: + return default + return raw.startswith('y') + + +# ═══════════════════════════════════════════════════════════════════════════ +# LLM 配置逻辑 +# ═══════════════════════════════════════════════════════════════════════════ + +def _get_proxy_handler(): + """从环境变量读取代理配置,返回 ProxyHandler 或 None""" + for var in ('HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'): + url = os.environ.get(var) + if url: + return urllib.request.ProxyHandler({'https': url, 'http': url}) + return None + +def probe_models(provider, apikey, apibase=None): + """调用 API 探测可用模型列表,返回模型 ID 列表或 None""" + ptype = provider.get('type', 'native_oai') + base = (apibase or provider['template'].get('apibase', '')).rstrip('/') + + if ptype == 'native_claude': + url = f"{base}/v1/models" + headers = {'x-api-key': apikey, 'anthropic-version': '2023-06-01', 'User-Agent': 'GenericAgent/1.0'} + else: + url = f"{base}/models" + headers = {'Authorization': f'Bearer {apikey}', 'User-Agent': 'GenericAgent/1.0'} + + print(f"\n {C['dim']}🔍 正在探测可用模型 ({base}/models)...{C['reset']}", end='', flush=True) + if ptype == 'native_claude': + print(f" {C['dim']}(Anthropic 协议,探测可能失败){C['reset']}", end='', flush=True) + + opener = urllib.request.build_opener() + ph = _get_proxy_handler() + if ph: + opener = urllib.request.build_opener(ph) + print(f" {C['dim']}(via proxy){C['reset']}", end='', flush=True) + + for attempt in range(2): + try: + req = urllib.request.Request(url, headers=headers, method='GET') + with opener.open(req, timeout=10) as resp: + data = json.loads(resp.read().decode()) + models = data.get('data', []) + ids = sorted(set(m['id'] for m in models if isinstance(m, dict) and m.get('id'))) + if ids: + print(f" {C['green']}✓ 发现 {len(ids)} 个模型{C['reset']}") + return ids + print(f" {C['yellow']}⚠ 返回为空{C['reset']}") + return None + except Exception as e: + if attempt == 0 and 'timeout' in type(e).__name__.lower(): + print(f" {C['yellow']}⏱ 超时,重试...{C['reset']}", end='', flush=True) + continue + print(f" {C['yellow']}⚠ 探测失败: {type(e).__name__}(将使用预设列表){C['reset']}") + return None + return None + +def _normalize_model_choices(choices): + """统一 model_choices 格式为 [{'id': str, 'name': str}]""" + if not choices: + return [] + result = [] + for item in choices: + if isinstance(item, str): + result.append({'id': item, 'name': item}) + elif isinstance(item, dict): + result.append(item) + elif isinstance(item, (tuple, list)) and len(item) >= 1: + result.append({'id': item[0], 'name': item[1] if len(item) > 1 else item[0]}) + return result + +def _configure_advanced(provider, cfg): + """配置高级可选字段: proxy, context_win, stream, user_agent, thinking_budget_tokens""" + print(f"\n {C['dim']}── 高级选项(回车跳过,使用默认值){C['reset']}") + proxy = ask_input("HTTP 代理地址 (proxy)", default='', hint='如 http://127.0.0.1:2082,留空跳过') + if proxy: + cfg['proxy'] = proxy + cw = ask_input("上下文窗口阈值 (context_win)", default='', hint='NativeClaude 默认 28000,其他默认 24000') + if cw: + cfg['context_win'] = int(cw) + if cfg.get('thinking_type') == 'enabled': + tbt = ask_input("thinking_budget_tokens", default='', hint='low≈4096, medium≈10240, high≈32768') + if tbt: + cfg['thinking_budget_tokens'] = int(tbt) + if cfg.get('type', provider['type']) == 'native_claude': + ua = ask_input("User-Agent 版本号", default='', hint='某些中转按 UA 白名单校验,pin 老版本用') + if ua: + cfg['user_agent'] = ua + stream_default = cfg.get('stream', True) + if ask_yesno("启用 SSE 流式 (stream)", default=stream_default): + cfg['stream'] = True + else: + cfg['stream'] = False + +def configure_llm(provider): + """引导用户配置单个模型""" + print(f"\n{C['cyan']}{'─'*60}{C['reset']}") + print(f"{C['bold']} 配置: {provider['name']}{C['reset']}") + print(f" {C['dim']}{provider['desc']}{C['reset']}") + print(f"{C['cyan']}{'─'*60}{C['reset']}") + + cfg = dict(provider['template']) + + # API Key(密文输入) + cfg['apikey'] = ask_input( + f"API Key", + hint=provider.get('key_hint', ''), + secret=True, + ) + + # 额外字段 + for field in provider.get('extra_fields', []): + if field['key'] == 'apibase': + cfg['apibase'] = ask_input( + field['label'], + default=field.get('default', cfg.get('apibase', '')), + ) + elif field.get('type') == 'bool': + cfg[field['key']] = ask_yesno( + field['label'], + default=field.get('default', True) + ) + elif field.get('type') == 'choice': + picked = ask_choice(field['label'], field['options']) + chosen = next(o for o in field['options'] if o['id'] == picked) + for opt_key, opt_val in chosen.items(): + if opt_key not in ('id', 'name', 'desc'): + cfg[opt_key] = opt_val + + # 模型选择 + manual_choice = {'id': '__manual__', 'name': '✏️ 手动输入模型名', 'desc': '自定义模型 ID,不依赖探测结果'} + model_list = probe_models(provider, cfg['apikey'], cfg.get('apibase')) + if model_list: + refresh_choice = {'id': '__refresh__', 'name': '🔄 重新探测'} + choices = [refresh_choice, manual_choice] + [{'id': m, 'name': m} for m in model_list] + while True: + picked = ask_choice("API 探测到以下可用模型(或手动输入):", choices) + if picked == '__refresh__': + print(f" {C['dim']}再次探测...{C['reset']}") + model_list = probe_models(provider, cfg['apikey'], cfg.get('apibase')) + if not model_list: + print(f" {C['yellow']}⚠ 再次探测失败{C['reset']}") + picked = _fallback_model(provider, manual_choice) + break + choices = [refresh_choice, manual_choice] + [{'id': m, 'name': m} for m in model_list] + elif picked == '__manual__': + picked = ask_input("请输入模型名", default=cfg.get('model', '')) + break + else: + break + cfg['model'] = picked + else: + cfg['model'] = _fallback_model(provider, manual_choice) + + # 别名 + default_name = cfg.get('name', provider['id']) + name = ask_input("此配置的别名 (name,Mixin 引用用)", default=default_name) + if name: + cfg['name'] = name + + # 高级选项 + if ask_yesno("配置高级选项(proxy / context_win / stream 等)?", default=False): + _configure_advanced(provider, cfg) + + return cfg + +def _fallback_model(provider, manual_choice=None): + """使用预设模型列表让用户选择,始终提供手动输入选项""" + manual_choice = manual_choice or {'id': '__manual__', 'name': '✏️ 手动输入模型名', 'desc': '自定义模型 ID'} + normalized = _normalize_model_choices(provider.get('model_choices', [])) + if normalized: + choices = [manual_choice] + normalized + picked = ask_choice("选择模型(或手动输入):", choices) + if picked == '__manual__': + return ask_input("请输入模型名", default=provider['template'].get('model', '')) + return picked + return ask_input("请输入模型名", default=provider['template'].get('model', '')) + +def configure_llms(): + """配置 LLM 模型""" + print(f"\n{C['bold']}{C['magenta']}╔══════════════════════════════════════╗") + print(f"║ 第一步: 配置 LLM 模型 ║") + print(f"╚══════════════════════════════════════╝{C['reset']}") + print(f"\n{C['dim']} 你可以配置最多 2 个模型组成故障转移 (Mixin) 列表。{C['reset']}") + + all_cfgs = [] + provider_id = ask_choice("选择模型厂商 (配置第 1 个模型):", LLM_PROVIDERS) + provider = next(p for p in LLM_PROVIDERS if p['id'] == provider_id) + cfg = configure_llm(provider) + all_cfgs.append(cfg) + + if ask_yesno("再添加一个模型做故障转移?", default=False): + providers_ext = [{'id': '__stop__', 'name': '✓ 不需要备选了', 'desc': ''}] + LLM_PROVIDERS + provider_id = ask_choice( + "选择模型厂商 (配置第 2 个模型 — 或选「不需要备选了」跳过):", + providers_ext + ) + if provider_id != '__stop__': + provider = next(p for p in LLM_PROVIDERS if p['id'] == provider_id) + cfg = configure_llm(provider) + all_cfgs.append(cfg) + + return all_cfgs + + +# ═══════════════════════════════════════════════════════════════════════════ +# 消息平台配置逻辑 +# ═══════════════════════════════════════════════════════════════════════════ + +def configure_platforms(): + """配置消息平台,返回 (platform_configs, pip_hints)""" + print(f"\n{C['bold']}{C['magenta']}╔══════════════════════════════════════╗") + print(f"║ 第二步: 配置消息平台 ║") + print(f"╚══════════════════════════════════════╝{C['reset']}") + print(f"\n{C['dim']} 消息平台用于从聊天软件与 Agent 交互。{C['reset']}") + print(f"{C['dim']} 你也可以跳过此步,直接用终端 REPL。{C['reset']}") + + platform_ids = ask_choice( + "选择消息平台 (可多选,选 '不使用' 则跳过):", + PLATFORMS, + allow_multi=True, + default=['none'] + ) + + if 'none' in platform_ids: + return [], set() + + selected_platforms = [] + pip_hints = set() + + for pid in platform_ids: + platform = next(p for p in PLATFORMS if p['id'] == pid) + pip_hints.update(platform.get('deps', [])) + + print(f"\n{C['cyan']}{'─'*60}{C['reset']}") + print(f"{C['bold']} 配置: {platform['name']}{C['reset']}") + print(f"{C['cyan']}{'─'*60}{C['reset']}") + + env_vals = {} + + if pid == 'feishu' and ask_yesno("使用一键扫码创建应用?(推荐)", default=True): + env_vals = _feishu_scan(platform) + if pid == 'wechat' and ask_yesno("扫码登录微信 iLink?(推荐)", default=True): + env_vals = _wechat_scan() + + for var in platform['env_vars']: + if var['key'] not in env_vals: + env_vals.update(_manual_platform_var(var)) + + if pid == 'wecom' and ask_yesno("设置欢迎消息?", default=False): + env_vals['wecom_welcome_message'] = ask_input("欢迎消息内容", default='你好,我在线上。') + + selected_platforms.append({'platform': platform, 'config': env_vals}) + + return selected_platforms, pip_hints + +def _manual_platform_var(var): + """手动填写单个平台变量""" + val = ask_input(var['label'], hint=var.get('hint', ''), default=var.get('default')) + if var.get('is_list'): + if val == '[]' or not val: + return {var['key']: []} + return {var['key']: [x.strip() for x in val.split(',') if x.strip()]} + return {var['key']: val} + +def _feishu_scan(platform): + """飞书一键扫码创建应用,返回 env_vals 或空 dict""" + from io import StringIO + try: + import lark_oapi as lark + import qrcode, threading + except ImportError: + print(f"\n {C['yellow']}⚠ lark-oapi 未安装,降级为手动配置{C['reset']}") + return {} + + print(f"\n {C['cyan']}📱 正在启动一键创建...{C['reset']}") + print(f" {C['dim']} 请用飞书 App 扫描终端二维码,完成授权后自动获取凭据。{C['reset']}\n") + + qr_printed = threading.Event() + result_holder = {'data': None} + + def handle_qr(info): + url = info['url'] + expire = info['expire_in'] + qr = qrcode.QRCode(border=1, box_size=1) + qr.add_data(url) + buf = StringIO() + qr.print_ascii(out=buf) + qr_art = buf.getvalue() + print(f"\n {C['bold']}请用飞书扫描下方二维码,或复制链接在浏览器打开:{C['reset']}") + print(f" {C['green']}{qr_art.replace(chr(27), '')}{C['reset']}") + print(f" {C['dim']} 链接: {url}{C['reset']}") + print(f" {C['dim']} 有效期 {expire} 秒{C['reset']}") + qr_printed.set() + + def handle_status(info): + status = info['status'] + if status == 'polling': + print(f" {C['yellow']}⏳ 等待扫码...{C['reset']}") + elif status == 'slow_down': + print(f" {C['yellow']}⏳ 等待中... (间隔 {info.get('interval', '?')}s){C['reset']}") + elif status == 'domain_switched': + print(f" {C['cyan']}🌐 已切换认证域名{C['reset']}") + + def run_register(): + try: + result = lark.register_app( + on_qr_code=handle_qr, + on_status_change=handle_status, + ) + result_holder['data'] = result + except Exception as e: + print(f"\n {C['red']}✗ 创建失败: {e}{C['reset']}") + + thread = threading.Thread(target=run_register, daemon=True) + thread.start() + qr_printed.wait(timeout=15) + thread.join(timeout=300) + + if result_holder['data']: + result = result_holder['data'] + print(f"\n {C['green']}✅ 应用创建成功!{C['reset']}") + print(f" App ID: {C['bold']}{result['client_id']}{C['reset']}") + print(f" App Secret: {C['bold']}{result['client_secret']}{C['reset']}") + return { + 'fs_app_id': result['client_id'], + 'fs_app_secret': result['client_secret'], + } + else: + print(f"\n {C['yellow']}⚠ 扫码创建未完成,降级为手动填写...{C['reset']}") + return {} + + +def _wechat_scan(): + """微信 iLink 扫码登录,保存 token 到 ~/.wxbot/token.json,返回 env_vals""" + print(f"\n {C['cyan']}📱 正在启动微信 iLink 扫码登录...{C['reset']}") + print(f" {C['dim']} 请用微信扫描终端二维码,完成授权后自动获取凭据。{C['reset']}\n") + + # 确保项目根在路径中,以便导入 frontends/wechatapp + if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + try: + from frontends.wechatapp import WxBotClient + except ImportError as e: + print(f"\n {C['yellow']}⚠ 无法导入 WxBotClient: {e}{C['reset']}") + return {} + + try: + bot = WxBotClient() + if bot.token: + print(f" {C['green']}✅ 已有有效 token (bot_id={bot.bot_id}){C['reset']}") + if ask_yesno("重新扫码登录?", default=False): + bot.token = '' + else: + return {} + bot.login_qr() + print(f"\n {C['green']}✅ 微信 iLink 扫码登录成功!{C['reset']}") + print(f" Bot ID: {C['bold']}{bot.bot_id}{C['reset']}") + print(f" Token 已保存到: {C['dim']}{bot._tf}{C['reset']}") + except Exception as e: + print(f"\n {C['red']}✗ 扫码登录失败: {e}{C['reset']}") + return {} + + return {} + + + +# ═══════════════════════════════════════════════════════════════════════════ +# 生成 mykey.py +# ═══════════════════════════════════════════════════════════════════════════ + +def _var_type_info(cfg): + """根据配置类型返回 (var_prefix, session_type)""" + cfg_type = cfg.get('type', 'native_oai') + if cfg_type == 'native_claude': + return 'native_claude_config', 'NativeClaudeSession' + elif cfg_type == 'claude': + return 'claude_config', 'ClaudeSession' + elif cfg_type == 'oai': + return 'oai_config', 'LLMSession' + else: + return 'native_oai_config', 'NativeOAISession' + + +def generate_mykey(llm_cfgs, platform_configs): + """生成 mykey.py 内容""" + lines = [] + lines.append("# ══════════════════════════════════════════════════════════════════════════════") + lines.append(f"# GenericAgent — mykey.py (由 configure.py 自动生成 @ {datetime.now().strftime('%Y-%m-%d %H:%M')})") + lines.append("# ══════════════════════════════════════════════════════════════════════════════") + lines.append("") + lines.append("# ── 停止符 ──────────────────────────────────────────────────────────────────") + lines.append("_SETUP_DONE = 'configure.py' # 删除此行可重新触发配置向导") + lines.append("") + + # Mixin 配置 + names = [c['name'] for c in llm_cfgs] + lines.append("# ── Mixin 故障转移 ──────────────────────────────────────────────────────────") + lines.append("mixin_config = {") + lines.append(f" 'llm_nos': {names},") + lines.append(" 'max_retries': 10,") + lines.append(" 'base_delay': 0.5,") + lines.append("}") + lines.append("") + + # 各模型配置 + type_counts = {} + for cfg in llm_cfgs: + cfg_type = cfg.get('type', 'native_oai') + type_counts[cfg_type] = type_counts.get(cfg_type, 0) + 1 + + type_indices = {} + for i, cfg in enumerate(llm_cfgs): + cfg_type = cfg.get('type', 'native_oai') + var_prefix, session_type = _var_type_info(cfg) + idx = type_indices.get(cfg_type, 0) + type_indices[cfg_type] = idx + 1 + + if type_counts[cfg_type] > 1: + var_name = f"{var_prefix}_{idx}" + else: + var_name = var_prefix + + lines.append(f"# ── {cfg['name']} ({session_type}) ─────────────────────────────────────────────") + lines.append(f"{var_name} = {{") + _write_config_fields(lines, cfg) + lines.append("}") + lines.append("") + + # 平台配置 + if platform_configs: + lines.append("# ══════════════════════════════════════════════════════════════════════════════") + lines.append("# 聊天平台集成") + lines.append("# ══════════════════════════════════════════════════════════════════════════════") + lines.append("") + for pc in platform_configs: + for key, val in pc['config'].items(): + _write_platform_value(lines, key, val) + lines.append("") + + # 尾部 + lines.append("# ══════════════════════════════════════════════════════════════════════════════") + lines.append("# 配置完毕!运行: python agentmain.py (终端 REPL)") + if platform_configs: + for pc in platform_configs: + p = pc['platform'] + lines.append(f"# 或: python {p['file']} ({p['name']})") + lines.append("# ══════════════════════════════════════════════════════════════════════════════") + + return '\n'.join(lines) + +def _write_config_fields(lines, cfg): + """写入配置字典的键值对(缩进的 'key': value, 格式)""" + for key in ['name', 'type', 'apikey', 'apibase', 'model', 'api_mode', + 'fake_cc_system_prompt', 'thinking_type', 'thinking_budget_tokens', + 'reasoning_effort', 'max_tokens', 'max_retries', 'connect_timeout', + 'read_timeout', 'temperature', 'context_win', + 'proxy', 'user_agent', 'stream']: + if key not in cfg: + continue + val = cfg[key] + if isinstance(val, bool): + lines.append(f" '{key}': {str(val)},") + elif isinstance(val, (int, float)): + lines.append(f" '{key}': {val},") + elif isinstance(val, str): + lines.append(f" '{key}': '{val}',") + else: + lines.append(f" '{key}': {repr(val)},") + +def _write_platform_value(lines, key, val): + """写入顶级变量(平台配置等)""" + if isinstance(val, list): + if val: + lines.append(f"{key} = {repr(val)}") + else: + lines.append(f"{key} = [] # 允许所有用户") + elif isinstance(val, str): + lines.append(f"{key} = '{val}'") + else: + lines.append(f"{key} = {repr(val)}") + + +def _parse_existing_mykey(): + """解析已有 mykey.py,返回 (model_names, platform_infos) + + model_names: [str] — 模型名列表 + platform_infos: [{'id': str, 'vars': [{'key': str, 'val': ...}]}] — 平台信息 + 解析失败时返回 ([], []) + """ + if not os.path.exists(MYKPY_PATH): + return [], [] + + with open(MYKPY_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + # 解析模型名 + model_names = [] + m = re.search(r"'llm_nos':\s*\[([^\]]*)\]", content) + if m: + model_names = re.findall(r"'([^']+)'", m.group(1)) + + # 先收集所有已知平台 env var key → 判断值类型 + all_env_var_keys = {} + platform_env_keys = {} # pid -> [var_key] + for p in PLATFORMS: + pid = p['id'] + platform_env_keys.setdefault(pid, []) + for var in p.get('env_vars', []): + vkey = var['key'] + all_env_var_keys[vkey] = var + platform_env_keys[pid].append(vkey) + + # 逐平台解析所有已知变量 + platform_infos = [] + for pid, env_keys in platform_env_keys.items(): + vars_found = [] + for vkey in env_keys: + var_def = all_env_var_keys[vkey] + val = None + if var_def.get('is_list'): + # 匹配 `xxx = [...]` + m_var = re.search(rf"^{vkey}\s*=\s*(\[[^\]]*\])", content, re.MULTILINE) + if m_var: + try: + val = ast.literal_eval(m_var.group(1)) + except (ValueError, SyntaxError): + pass + else: + # 匹配 `xxx = '...'` + m_var = re.search(rf"^{vkey}\s*=\s*'([^']*)'", content, re.MULTILINE) + if m_var: + val = m_var.group(1) + if val is not None: + vars_found.append({'key': vkey, 'val': val}) + if vars_found: + platform_infos.append({'id': pid, 'vars': vars_found}) + + return model_names, platform_infos + + +def _parse_existing_llm_cfgs(): + """解析已有 mykey.py,返回完整 LLM 配置字典列表 [{name, apikey, ...}] + 解析失败时返回 [] + """ + if not os.path.exists(MYKPY_PATH): + return [] + + with open(MYKPY_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + cfgs = [] + # 匹配所有 `xxx = { ... }` 顶层字典赋值 + # 用简单状态机: 找 `\w+ = {` 然后匹配花括号 + pattern = re.compile(r'^(\w+)\s*=\s*\{', re.MULTILINE) + for m in pattern.finditer(content): + brace_start = m.end() - 1 # '{' 的位置 + depth = 1 + i = brace_start + 1 + while i < len(content) and depth > 0: + if content[i] == '{': + depth += 1 + elif content[i] == '}': + depth -= 1 + i += 1 + if depth == 0: + dict_text = content[m.end():i - 1] + try: + d = ast.literal_eval('{' + dict_text + '}') + if isinstance(d, dict) and 'name' in d: + cfgs.append(d) + except (ValueError, SyntaxError): + continue + + return cfgs + + +def _backup_with_name(model_names, platform_ids): + """按 mykey+模型名+机器人名 格式备份旧 mykey.py""" + parts = ['mykey'] + for m in model_names[:3]: + parts.append(m.replace('/', '-').replace('\\', '-')) + for pid in platform_ids: + pid_clean = pid.replace('_', '') + if pid_clean not in parts: + parts.append(pid_clean) + safe_name = '_'.join(parts) + if safe_name == 'mykey': + safe_name = 'mykey_backup' # 避免和源文件同名 + if len(safe_name) > 100: + safe_name = safe_name[:100] + backup_path = os.path.join(PROJECT_ROOT, f'{safe_name}.py') + shutil.copy2(MYKPY_PATH, backup_path) + return backup_path + + +# ═══════════════════════════════════════════════════════════════════════════ +# Main +# ═══════════════════════════════════════════════════════════════════════════ + +def main(): + banner() + + # Python 版本检查 + ok, msg = _check_python() + if not ok: + print(f" {C['red']}✗ {msg}{C['reset']}") + sys.exit(1) + if msg: + color = 'yellow' if '⚠' in msg else 'green' + print(f" {C[color]}{msg}{C['reset']}\n") + + # ── 决策流程 ── + llm_cfgs = [] + platform_configs = [] + platform_deps = set() + is_modify = False + is_new = False + + if os.path.exists(MYKPY_PATH): + model_names, platform_infos = _parse_existing_mykey() + cur_models = ', '.join(model_names) if model_names else '(未知)' + cur_platforms = ', '.join(p['id'] for p in platform_infos) if platform_infos else '(无)' + print(f" {C['dim']} 当前: 模型=[{cur_models}], 平台=[{cur_platforms}]{C['reset']}") + + mode = ask_choice( + "检测到已有 mykey.py,请选择操作", + [ + {'id': 'modify', 'name': '修改现有配置', 'desc': '保留未改部分,只重新配置选定项'}, + {'id': 'new', 'name': '新建配置(备份旧文件)', 'desc': '备份为 mykey+模型+平台.py,然后全新配置'}, + ], + default=None, + ) + + if mode == 'new': + backup_path = _backup_with_name(model_names, [p['id'] for p in platform_infos]) + print(f" {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup_path}{C['reset']}") + is_new = True + else: + is_modify = True + scope = ask_choice( + "你要修改什么?", + [ + {'id': 'both', 'name': '两项都重新配置', 'desc': 'LLM + 平台全部更新'}, + {'id': 'llm', 'name': '重新配置 LLM 模型', 'desc': f'当前: {cur_models}'}, + {'id': 'platform', 'name': '重新配置消息平台', 'desc': f'当前: {cur_platforms}'}, + ], + ) + if scope in ('llm', 'both'): + llm_cfgs = _do_llm() + if scope in ('platform', 'both'): + platform_configs, platform_deps = configure_platforms() + if scope == 'llm' and platform_infos: + for pi in platform_infos: + p = next((x for x in PLATFORMS if x['id'] == pi['id']), None) + if p: + config_dict = {v['key']: v['val'] for v in pi['vars']} + platform_configs.append({'platform': p, 'config': config_dict}) + elif scope == 'platform' and model_names: + old_cfgs = _parse_existing_llm_cfgs() + if old_cfgs: + llm_cfgs = old_cfgs + print(f"\n {C['green']}✓ 已保留现有 LLM 配置: {', '.join(c['name'] for c in old_cfgs)}{C['reset']}") + else: + print(f"\n {C['yellow']}⚠ 保留 LLM 配置失败,将生成空配置。建议两项都重新配置。{C['reset']}") + + if not is_modify: + if is_new: + hint = "已备份旧配置,请完成全新设置" + else: + hint = "首次配置,建议同时设置模型和消息平台" + print(f" {C['dim']} {hint}。{C['reset']}") + + scope = ask_choice( + "你想配置什么?", + [ + {'id': 'both', 'name': '两项都配置 (推荐)', 'desc': 'LLM 模型 + 消息平台,完整初始化'}, + {'id': 'llm', 'name': '仅 LLM 模型', 'desc': '只配置模型,稍后再配平台'}, + {'id': 'platform', 'name': '仅消息平台', 'desc': '只配平台,稍后再配模型'}, + ], + default='both', + ) + + if scope in ('llm', 'both'): + llm_cfgs = _do_llm() + if scope == 'llm': + if ask_yesno("是否继续配置消息平台?", default=True): + platform_configs, platform_deps = configure_platforms() + + if scope == 'both': + platform_configs, platform_deps = configure_platforms() + + if scope == 'platform': + platform_configs, platform_deps = configure_platforms() + if ask_yesno("是否继续配置 LLM 模型?", default=True): + llm_cfgs = _do_llm() + elif os.path.exists(MYKPY_PATH): + # 新建+仅平台:从备份保留旧 LLM 配置 + old_cfgs = _parse_existing_llm_cfgs() + if old_cfgs: + llm_cfgs = old_cfgs + print(f"\n {C['green']}✓ 已保留备份中的 LLM 配置: {', '.join(c['name'] for c in old_cfgs)}{C['reset']}") + + # ── 生成 mykey.py ── + if not llm_cfgs and not platform_configs: + print(f"\n {C['yellow']}⚠ 没有配置任何内容,退出。{C['reset']}") + sys.exit(0) + + content = generate_mykey(llm_cfgs, platform_configs) + + # 备份旧文件(修改模式不备份,直接在原文件修改) + if os.path.exists(MYKPY_PATH) and not is_modify and not is_new: + backup = _backup_with_name(model_names, [p['id'] for p in platform_infos]) + print(f"\n {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup}{C['reset']}") + + # 写入 + with open(MYKPY_PATH, 'w', encoding='utf-8') as f: + f.write(content) + print(f"\n {C['green']}✓ mykey.py 已生成!{C['reset']}") + + # ── 完成提示 ── + print(f"\n{C['bold']}{C['green']}╔══════════════════════════════════════╗") + print(f"║ 配置完成! ║") + print(f"╚══════════════════════════════════════╝{C['reset']}") + print() + if llm_cfgs: + print(f" {C['cyan']} 终端 REPL:{C['reset']} python agentmain.py") + if platform_configs: + for i, pc in enumerate(platform_configs, 1): + p = pc['platform'] + print(f" {C['cyan']} 平台 {i} ({p['name']}):{C['reset']} python {p['file']}") + print() + + # pip 依赖提示 + all_deps = sorted(platform_deps) + if all_deps: + print(f" {C['yellow']}💡 提示:你需要安装以下依赖以使消息平台正常工作:{C['reset']}") + print(f" {C['cyan']}pip install {' '.join(all_deps)}{C['reset']}") + print() + + # ── 入门示例 ── + print(f" {C['bold']}试试这些命令:{C['reset']}") + examples = [ + "帮我在桌面创建一个 hello.txt,内容是 Hello World", + "请查看你的代码,安装所有用得上的 python 依赖", + "执行 web setup sop,解锁 web 工具", + "打开淘宝,搜索 iPhone 16,按价格排序", + "用rapidocr配置你的ocr能力并存入记忆", + "git 更新你的代码,然后看看 commit 有什么新功能", + "把这个记到你的记忆里", + ] + for ex in examples: + print(f" {C['dim']}{ex}{C['reset']}") + print() + + print(f" {C['green']}{C['bold']}合抱之木,生于毫末{C['reset']}\n") + + +def _do_llm(): + """配置 LLM 模型,失败则 exit。""" + cfgs = configure_llms() + if not cfgs: + print(f"\n {C['red']}✗ 至少需要配置一个模型才能使用。退出。{C['reset']}") + sys.exit(1) + return cfgs + + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print(f"\n\n {C['yellow']}⚠ 用户中断{C['reset']}") + sys.exit(0) diff --git a/assets/demo/alipay_expense.png b/assets/demo/alipay_expense.png new file mode 100644 index 0000000..2c3b495 Binary files /dev/null and b/assets/demo/alipay_expense.png differ diff --git a/assets/demo/autonomous_explore.png b/assets/demo/autonomous_explore.png new file mode 100644 index 0000000..c51d8c3 Binary files /dev/null and b/assets/demo/autonomous_explore.png differ diff --git a/assets/demo/codeg-demo.gif b/assets/demo/codeg-demo.gif new file mode 100644 index 0000000..cd120ef Binary files /dev/null and b/assets/demo/codeg-demo.gif differ diff --git a/assets/demo/discord_hcaptcha_real_browser.gif b/assets/demo/discord_hcaptcha_real_browser.gif new file mode 100644 index 0000000..5630e8c Binary files /dev/null and b/assets/demo/discord_hcaptcha_real_browser.gif differ diff --git a/assets/demo/order_tea.gif b/assets/demo/order_tea.gif new file mode 100644 index 0000000..38b6086 Binary files /dev/null and b/assets/demo/order_tea.gif differ diff --git a/assets/demo/selectstock.gif b/assets/demo/selectstock.gif new file mode 100644 index 0000000..6807df7 Binary files /dev/null and b/assets/demo/selectstock.gif differ diff --git a/assets/demo/wechat_batch.png b/assets/demo/wechat_batch.png new file mode 100644 index 0000000..82b77d2 Binary files /dev/null and b/assets/demo/wechat_batch.png differ diff --git a/assets/ga_httpapp.py b/assets/ga_httpapp.py new file mode 100644 index 0000000..c479e9c --- /dev/null +++ b/assets/ga_httpapp.py @@ -0,0 +1,125 @@ +import threading, sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from fastapi import FastAPI, Header, HTTPException, Query, Depends; from fastapi.responses import HTMLResponse +from pydantic import BaseModel +from agentmain import GenericAgent as GA + +PORT, API_KEY = int(sys.argv[1]), sys.argv[2] +app, agent, lock = FastAPI(), GA(), threading.Lock() +outputs, stopped = [], True +threading.Thread(target=agent.run, daemon=True).start() +class Req(BaseModel): prompt: str = "" +agent.verbose = False + +def require_key(key: str = Query(None), x_api_key: str = Header(None, alias="X-API-Key")): + if API_KEY not in (key, x_api_key): raise HTTPException(404) + +def run_task(prompt): + global stopped + segs = [] # 本任务按 turn 索引的分段输出 + with lock: task_start = len(outputs) + def flush(): + with lock: outputs[task_start:] = segs + try: + dq = agent.put_task(prompt, source="http") + while "done" not in (item := dq.get(timeout=2200)): + outs = item.get("outputs") + if not outs: continue + idx = max(0, int(item.get("turn", 0) or 0) - 1) # turn 1-based → 槽位 0-based + while len(segs) <= idx: segs.append("") + segs[idx] = str(outs[-1]) # 当前 turn + if len(outs) >= 2 and idx >= 1: segs[idx - 1] = str(outs[-2]) # 前一 turn 落定值 + flush() + segs = [str(s) for s in item.get("outputs", [])] # done 时全量替换 + flush() + finally: stopped = True + +@app.post("/put_task") +def put_task(req: Req, _=Depends(require_key)): + global stopped + with lock: + if not stopped: return {"ok": False, "error": "should abort first"} + stopped = False + threading.Thread(target=run_task, args=(req.prompt,), daemon=True).start() + return {"ok": True} + +@app.post("/abort") +def abort(_=Depends(require_key)): agent.abort(); return {"ok": True} + +@app.post("/input") +def input_task(req: Req, _=Depends(require_key)): + global stopped + if not stopped: agent.intervene = req.prompt; return {"ok": True, "mode": "intervene"} + with lock: + if not stopped: agent.intervene = req.prompt; return {"ok": True, "mode": "intervene"} + stopped = False + threading.Thread(target=run_task, args=(req.prompt,), daemon=True).start() + return {"ok": True, "mode": "task"} + +@app.get("/output") +def get_output(k: int = Query(5), _=Depends(require_key)): + with lock: r = outputs[-k:] + return {"stopped": stopped, "output": "\n".join(r), + "history": "\n".join(str(h) for h in agent.history)} + +@app.get("/llm") +def llm_ep(llm_no: int = Query(None), _=Depends(require_key)): + if llm_no is not None: + agent.next_llm(llm_no) + return {"llm_no": agent.llm_no, "name": agent.get_llm_name(), + "llms": [{"no": i, "name": n, "current": a} for i, n, a in agent.list_llms()]} + +@app.get("/sysprompt") +def sysprompt_ep(text: str = Query(None), _=Depends(require_key)): + if text is not None: + agent.extra_sys_prompts = [text] if text else [] + return {"extra_sys_prompts": agent.extra_sys_prompts} + +HELP = """GA HTTP 操作协议(所有请求带 ?key=API_KEY,或 Header X-API-Key) +GET /output?k=N 查看状态:{stopped, output(末N条), history}。stopped=true 表示空闲 +POST /input {prompt} 下发指令:空闲时作为新任务,忙时作为中途干预(intervene) +POST /abort 中止当前任务 +GET /llm[?llm_no=N] 查/切模型:返回 {llm_no,name,llms:[{no,name,current}]} +GET /sysprompt[?text]查/设附加系统提示(extra_sys_prompts),text 为空则清空 +纠偏流程:先 GET /output 读 history 判断状态→需要时 POST /input 注入纠偏指令""" + +@app.get("/help") +def help_ep(_=Depends(require_key)): return {"help": HELP} + +@app.get("/") +def ui(): + return HTMLResponse(f""" + +GA Monitor + + +
● Loading...
+

Output

+

History

+ + +""") + +if __name__ == "__main__": + import uvicorn; uvicorn.run(app, host="0.0.0.0", port=PORT) diff --git a/assets/ga_install.ps1 b/assets/ga_install.ps1 new file mode 100644 index 0000000..5d5e195 --- /dev/null +++ b/assets/ga_install.ps1 @@ -0,0 +1,577 @@ +#requires -version 5.1 + +<#! + +GenericAgent one-click portable deployer for Windows. + + + +Modes: + + Default/Mainland: download GenericAgent.zip + uv + PortableGit from user's VPS, set China PyPI mirror. + + GLOBAL=1: clone GenericAgent from GitHub; uv and PortableGit also come from GitHub releases; no PyPI mirror. + + + +Portable components are installed under \.portable: + + uv, Python installed by uv, PortableGit. + +#> + +param( + + [string]$InstallDir = "$env:USERPROFILE\GenericAgent", + + [string]$PythonVersion = "3.12", + + [switch]$Force + +) + + + +$ErrorActionPreference = "Stop" + + + +# Make Chinese output reliable in Windows PowerShell 5.1 and redirected logs. + +try { + + [Console]::InputEncoding = [System.Text.Encoding]::UTF8 + + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + + $OutputEncoding = [System.Text.Encoding]::UTF8 + +} catch { } + + + +$RepoUrl = "https://github.com/lsdefine/GenericAgent.git" + +$VpsBase = "http://47.101.182.29:9000" + +$GaZipUrl = "$VpsBase/files/GenericAgent.zip" + +$UvUrl = "$VpsBase/uv/uv-x86_64-pc-windows-msvc.zip" + +$GitUrl = "$VpsBase/files/PortableGit-2.54.0-64-bit.7z.exe" + +$Deps = @("requests>=2.28", "beautifulsoup4>=4.12", "bottle>=0.12", "simple-websocket-server>=0.4", "streamlit>=1.28") + +$MainlandIndex = "https://pypi.tuna.tsinghua.edu.cn/simple" + +$GlobalMode = ($env:GLOBAL -eq "1") + +if ($GlobalMode) { + # GLOBAL=1: fetch everything from GitHub; no mainland endpoints involved. + $UvUrl = "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip" + $GitUrl = "https://github.com/git-for-windows/git/releases/download/v2.54.0.windows.1/PortableGit-2.54.0-64-bit.7z.exe" +} + + + +$GaDir = [IO.Path]::GetFullPath($InstallDir) + +$PortableRoot = Join-Path $GaDir ".portable" + +$Bin = Join-Path $PortableRoot "bin" + +$Cache = Join-Path $PortableRoot "cache" + +$Tools = Join-Path $PortableRoot "tools" + +$UvZip = Join-Path $Cache "uv-x86_64-pc-windows-msvc.zip" + +$GaZip = Join-Path $Cache "GenericAgent.zip" + +$GitExeArchive = Join-Path $Cache "PortableGit-2.54.0-64-bit.7z.exe" + +$UvExtract = Join-Path $Cache "uv-extract" + +$GaExtract = Join-Path $Cache "ga-extract" + +$GitDir = Join-Path $Tools "PortableGit" + +$UvExe = Join-Path $Bin "uv.exe" + +$GitExe = Join-Path $GitDir "bin\git.exe" + +$EnvCmd = Join-Path $GaDir "env.cmd" + +$EnvPs1 = Join-Path $GaDir "env.ps1" + + + +function Say($m) { Write-Host "[ga-deploy] $m" -ForegroundColor Cyan } + +function Ok($m) { Write-Host "[ok] $m" -ForegroundColor Green } + +function Die($m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 } + +function Invoke-Native([scriptblock]$Command) { + + $prevEAP = $ErrorActionPreference + + $ErrorActionPreference = 'Continue' + + try { & $Command } finally { $ErrorActionPreference = $prevEAP } + + return $LASTEXITCODE + +} + + + +function Download-File($Url, $OutFile) { + + New-Item -ItemType Directory -Force -Path (Split-Path $OutFile) | Out-Null + + Say "Downloading $Url" + + $wc = New-Object System.Net.WebClient + + $wc.Headers.Add("User-Agent", "Mozilla/5.0 ga-deploy") + + try { $wc.DownloadFile($Url, $OutFile) } finally { $wc.Dispose() } + + if (!(Test-Path $OutFile) -or ((Get-Item $OutFile).Length -lt 1024)) { Die "Download failed: $Url" } + +} + + + +function Expand-ZipClean($Zip, $Dest) { + + if (Test-Path $Dest) { Remove-Item -Recurse -Force $Dest } + + New-Item -ItemType Directory -Force -Path $Dest | Out-Null + + Expand-Archive -Path $Zip -DestinationPath $Dest -Force + +} + + + +function Copy-DirectoryContents($Src, $Dst) { + + New-Item -ItemType Directory -Force -Path $Dst | Out-Null + + Get-ChildItem -LiteralPath $Src -Force | ForEach-Object { + + Copy-Item -LiteralPath $_.FullName -Destination $Dst -Recurse -Force + + } + +} + + + +Say "Install dir: $GaDir" + +Say "Mode: $(if ($GlobalMode) { 'GLOBAL=1 / GitHub clone' } else { 'Mainland / VPS zip' })" + + + +if ((Test-Path $GaDir) -and $Force) { Remove-Item -Recurse -Force $GaDir } + +New-Item -ItemType Directory -Force -Path $GaDir,$PortableRoot,$Bin,$Cache,$Tools | Out-Null + + + +# uv (GitHub release in GLOBAL mode, user's VPS otherwise) + +if (!(Test-Path $UvExe) -or $Force) { + + Download-File $UvUrl $UvZip + + Expand-ZipClean $UvZip $UvExtract + + $foundUv = Get-ChildItem -Path $UvExtract -Recurse -Filter "uv.exe" | Select-Object -First 1 + + if (!$foundUv) { Die "uv.exe not found in archive" } + + Copy-Item $foundUv.FullName $UvExe -Force + +} + +Ok "uv: $(& $UvExe --version)" + + + +# Configure portable Python location. Mirror only in mainland mode. + +$env:UV_PYTHON_INSTALL_DIR = Join-Path $PortableRoot "uv-python" + +$env:UV_CACHE_DIR = Join-Path $PortableRoot "uv-cache" + +if ($GlobalMode) { + + Remove-Item Env:UV_DEFAULT_INDEX -ErrorAction SilentlyContinue + + Remove-Item Env:PIP_INDEX_URL -ErrorAction SilentlyContinue + +} else { + + $env:UV_DEFAULT_INDEX = $MainlandIndex + + $env:PIP_INDEX_URL = $MainlandIndex + +} + +$env:PATH = "$Bin;$env:PATH" + + + + +# Workaround: uv creates minor-version symlinks (junctions) in UV_PYTHON_INSTALL_DIR. +# If a previous interrupted install left a plain directory, uv fails with os error 4390. +# Fix: remove any non-junction subdirectory so uv can recreate them cleanly. +$uvPyDir = $env:UV_PYTHON_INSTALL_DIR +if (Test-Path $uvPyDir) { + Get-ChildItem -LiteralPath $uvPyDir -Directory | ForEach-Object { + $attr = $_.Attributes + if (($attr -band [IO.FileAttributes]::ReparsePoint) -eq 0) { + Say "Removing stale non-junction dir: $($_.Name)" + Remove-Item -LiteralPath $_.FullName -Recurse -Force + } + } +} + +Say "Installing Python $PythonVersion via uv" + +$ec = Invoke-Native { & $UvExe python install $PythonVersion } + +if ($ec -ne 0) { Die "uv python install failed" } + +$PythonExe = (& $UvExe python find $PythonVersion).Trim() + +if (!(Test-Path $PythonExe)) { Die "uv installed Python but python.exe was not found" } + +Ok "Python: $(& $PythonExe --version)" + + + +# PortableGit (GitHub release in GLOBAL mode, user's VPS otherwise). Needed for GLOBAL=1 and useful for user shell. + +if (!(Test-Path $GitExe) -or $Force) { + + Download-File $GitUrl $GitExeArchive + + if (Test-Path $GitDir) { Remove-Item -Recurse -Force $GitDir } + + New-Item -ItemType Directory -Force -Path $GitDir | Out-Null + + Say "Extracting PortableGit" + + $ec = Invoke-Native { & $GitExeArchive -y -o"$GitDir" | Out-Null } + + if ($ec -ne 0) { Die "PortableGit extraction failed" } + +} + +if (!(Test-Path $GitExe)) { Die "git.exe missing: $GitExe" } + +Ok "Git: $(& $GitExe --version)" + + + +$PythonDir = Split-Path $PythonExe -Parent + +$GitBin = Split-Path $GitExe -Parent + +$GitUsrBin = Join-Path $GitDir "usr\bin" + +$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;$env:PATH" + + + +# Fetch/update GenericAgent source. + +if ($GlobalMode) { + + Say "Cloning GenericAgent from GitHub" + + $items = @(Get-ChildItem -LiteralPath $GaDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ".portable" }) + + if ($items.Count -gt 0) { + + if (!$Force) { Die "Install dir contains files. Re-run with -Force to replace source while preserving portable tools." } + + $items | Remove-Item -Recurse -Force + + } + + $TmpClone = Join-Path $Cache "ga-clone" + + if (Test-Path $TmpClone) { Remove-Item -Recurse -Force $TmpClone } + + $ec = Invoke-Native { & $GitExe clone --depth 1 $RepoUrl $TmpClone } + + if ($ec -ne 0) { Die "git clone failed" } + + Copy-DirectoryContents $TmpClone $GaDir + + Remove-Item -Recurse -Force $TmpClone + +} else { + + Say "Downloading GenericAgent package from VPS" + + Download-File $GaZipUrl $GaZip + + Expand-ZipClean $GaZip $GaExtract + + $SrcDir = Join-Path $GaExtract "GenericAgent" + + if (!(Test-Path $SrcDir)) { $SrcDir = $GaExtract } + + $items = @(Get-ChildItem -LiteralPath $GaDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ".portable" }) + + if ($items.Count -gt 0) { $items | Remove-Item -Recurse -Force } + + Copy-DirectoryContents $SrcDir $GaDir + +} + +Ok "GenericAgent source ready: $GaDir" + + + +# Install basic dependencies and project in editable mode into portable Python. + +Say "Installing GenericAgent dependencies via uv pip" + +$installArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe) + +if (!$GlobalMode) { $installArgs += @("--index-url", $MainlandIndex) } + +$installArgs += $Deps + +$ec = Invoke-Native { & $UvExe @installArgs } + +if ($ec -ne 0) { Die "dependency install failed" } + + + +if (Test-Path (Join-Path $GaDir "pyproject.toml")) { + + $projectArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe) + + if (!$GlobalMode) { $projectArgs += @("--index-url", $MainlandIndex) } + + $projectArgs += @("-e", $GaDir) + + $ec = Invoke-Native { & $UvExe @projectArgs } + + if ($ec -ne 0) { Die "editable project install failed" } + +} + + + +# Try-install pywebview (optional UI). Failure is non-fatal. + +Say "Attempting to install pywebview (optional, failure is OK)" + +$webviewArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe) + +if (!$GlobalMode) { $webviewArgs += @("--index-url", $MainlandIndex) } + +$webviewArgs += @("pywebview>=4.0") + +$ec = Invoke-Native { & $UvExe @webviewArgs 2>&1 | Out-Null } + +if ($ec -ne 0) { + + Write-Host "[warn] pywebview install failed. This is optional." -ForegroundColor Yellow + + Write-Host " On Windows it usually works out of the box." -ForegroundColor Yellow + + Write-Host " If needed later: uv pip install pywebview" -ForegroundColor Yellow + +} else { + + Ok "pywebview installed successfully" + +} + + + +# Activation scripts: portable paths are intentionally before system PATH. + +if ($GlobalMode) { + +@" + +@echo off + +set "PORTABLE_DEV_ROOT=$PortableRoot" + +set "GENERICAGENT_HOME=$GaDir" + +set "UV_PYTHON_INSTALL_DIR=$PortableRoot\uv-python" + +set "UV_CACHE_DIR=$PortableRoot\uv-cache" + +set "PATH=$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;%PATH%" + +echo Activated GenericAgent portable env: %GENERICAGENT_HOME% + +"@ | Set-Content -Path $EnvCmd -Encoding ASCII + + + +@" + +`$env:PORTABLE_DEV_ROOT = "$PortableRoot" + +`$env:GENERICAGENT_HOME = "$GaDir" + +`$env:UV_PYTHON_INSTALL_DIR = "$PortableRoot\uv-python" + +`$env:UV_CACHE_DIR = "$PortableRoot\uv-cache" + +`$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;`$env:PATH" + +Write-Host "Activated GenericAgent portable env: `$env:GENERICAGENT_HOME" -ForegroundColor Green + +"@ | Set-Content -Path $EnvPs1 -Encoding UTF8 + +} else { + +@" + +@echo off + +set "PORTABLE_DEV_ROOT=$PortableRoot" + +set "GENERICAGENT_HOME=$GaDir" + +set "UV_PYTHON_INSTALL_DIR=$PortableRoot\uv-python" + +set "UV_CACHE_DIR=$PortableRoot\uv-cache" + +set "UV_DEFAULT_INDEX=$MainlandIndex" + +set "PIP_INDEX_URL=$MainlandIndex" + +set "PATH=$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;%PATH%" + +echo Activated GenericAgent portable env: %GENERICAGENT_HOME% + +"@ | Set-Content -Path $EnvCmd -Encoding ASCII + + + +@" + +`$env:PORTABLE_DEV_ROOT = "$PortableRoot" + +`$env:GENERICAGENT_HOME = "$GaDir" + +`$env:UV_PYTHON_INSTALL_DIR = "$PortableRoot\uv-python" + +`$env:UV_CACHE_DIR = "$PortableRoot\uv-cache" + +`$env:UV_DEFAULT_INDEX = "$MainlandIndex" + +`$env:PIP_INDEX_URL = "$MainlandIndex" + +`$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;`$env:PATH" + +Write-Host "Activated GenericAgent portable env: `$env:GENERICAGENT_HOME" -ForegroundColor Green + +"@ | Set-Content -Path $EnvPs1 -Encoding UTF8 + +} + + + +Ok "Verification:" + +& $UvExe --version + +& $PythonExe --version + +& $GitExe --version + +& $PythonExe -c "import requests, bs4, bottle; print('deps ok')" + +Write-Host "" + + + +# Copy mykey template if mykey.py does not exist (GLOBAL mode only) + +$MykeyDst = Join-Path $GaDir "mykey.py" + +if ($GlobalMode -and !(Test-Path $MykeyDst)) { + + $MykeyTpl = Join-Path $GaDir "mykey_template_en.py" + + if (Test-Path $MykeyTpl) { + + Copy-Item $MykeyTpl $MykeyDst + + Ok "Copied mykey_template_en.py -> mykey.py" + + } + +} + + + +# Final banner + +Write-Host "" + +if ($GlobalMode) { + + Write-Host @" + +╔═══════════════════════════════════════════════╗ + +║ ✅ GenericAgent installed successfully! ║ + +╠═══════════════════════════════════════════════╣ + +║ 📁 Location: $GaDir + +║ 🔑 Config: edit mykey.py (copied from template) + +║ 🚀 Launch: ga tui / ga launch / ga hub + +╚═══════════════════════════════════════════════╝ + +"@ + +} else { + + Write-Host @" + +╔═══════════════════════════════════════════════╗ + +║ [OK] GenericAgent 安装完成! ║ + +╠═══════════════════════════════════════════════╣ + +║ 安装目录: $GaDir + +║ 配置密钥: ga configure + +║ 启动: ga tui / ga launch / ga hub + +╚═══════════════════════════════════════════════╝ + +"@ + +} + +Write-Host "" + +Write-Host " Activate env: cmd.exe → call `"$EnvCmd`" | PowerShell → . `"$EnvPs1`"" + diff --git a/assets/ga_install.sh b/assets/ga_install.sh new file mode 100644 index 0000000..df6ad32 --- /dev/null +++ b/assets/ga_install.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +set -euo pipefail + +# GenericAgent one-click portable deployer for macOS/Linux. +# Modes: +# Default/Mainland: download GenericAgent.zip + uv from user's VPS, set China PyPI mirror. +# GLOBAL=1: clone GenericAgent from GitHub; uv also from GitHub releases; no PyPI mirror. +# Portable components are installed under /.portable: +# uv, Python installed by uv. On macOS/Linux git is expected from system package manager. + +INSTALL_DIR="${INSTALL_DIR:-$HOME/GenericAgent}" +PYTHON_VERSION="${PYTHON_VERSION:-3.12}" +FORCE="${FORCE:-0}" +GLOBAL="${GLOBAL:-0}" + +REPO_URL="https://github.com/lsdefine/GenericAgent.git" +VPS_BASE="http://47.101.182.29:9000" +GA_ZIP_URL="$VPS_BASE/files/GenericAgent.zip" +MAINLAND_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple" +DEPS=("requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "streamlit>=1.28") + +say(){ printf '\033[36m[ga-deploy]\033[0m %s\n' "$*"; } +ok(){ printf '\033[32m[ok]\033[0m %s\n' "$*"; } +die(){ printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; } + +usage(){ cat <<'EOF' +Usage: + bash install_portable_env.sh + INSTALL_DIR="$HOME/GenericAgent" PYTHON_VERSION=3.12 FORCE=1 bash install_portable_env.sh + GLOBAL=1 bash install_portable_env.sh + +Environment variables: + INSTALL_DIR Install GenericAgent here. Default: ~/GenericAgent + PYTHON_VERSION Python version installed by uv. Default: 3.12 + FORCE=1 Replace existing source files while preserving/reinstalling portable tools. + GLOBAL=1 Clone from GitHub directly and do not set China PyPI mirror. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage; exit 0; fi + +OS="$(uname -s)" +ARCH="$(uname -m)" +case "$OS:$ARCH" in + Darwin:x86_64) uv_file="uv-x86_64-apple-darwin.tar.gz" ;; + Darwin:arm64) uv_file="uv-aarch64-apple-darwin.tar.gz" ;; + Linux:x86_64) uv_file="uv-x86_64-unknown-linux-gnu.tar.gz" ;; + Linux:aarch64|Linux:arm64) uv_file="uv-aarch64-unknown-linux-gnu.tar.gz" ;; + *) die "Unsupported platform: $OS $ARCH" ;; +esac + +GA_DIR="${INSTALL_DIR/#\~/$HOME}" +case "$GA_DIR" in + /*) ;; + *) GA_DIR="$PWD/$GA_DIR" ;; +esac +mkdir -p "$GA_DIR" +GA_DIR="$(cd "$GA_DIR" && pwd -P)" +PORTABLE_ROOT="$GA_DIR/.portable" +BIN="$PORTABLE_ROOT/bin" +CACHE="$PORTABLE_ROOT/cache" +UV_TGZ="$CACHE/$uv_file" +GA_ZIP="$CACHE/GenericAgent.zip" +UV_EXTRACT="$CACHE/uv-extract" +GA_EXTRACT="$CACHE/ga-extract" +UV_EXE="$BIN/uv" +ENV_SH="$GA_DIR/env.sh" + +mkdir -p "$GA_DIR" "$PORTABLE_ROOT" "$BIN" "$CACHE" + +say "Install dir: $GA_DIR" +if [[ "$GLOBAL" == "1" ]]; then say "Mode: GLOBAL=1 / GitHub clone"; else say "Mode: Mainland / VPS zip"; fi + +if [[ "$FORCE" == "1" ]]; then + # Preserve .portable if present; remove source files later before deploying. + : +fi + +download(){ + local url="$1" out="$2" + mkdir -p "$(dirname "$out")" + say "Downloading $url" + if command -v curl >/dev/null 2>&1; then + curl -fL --retry 3 -A "ga-deploy" -o "$out" "$url" + elif command -v wget >/dev/null 2>&1; then + wget -O "$out" "$url" + else + die "curl or wget is required" + fi + [[ -s "$out" ]] || die "Download failed: $url" +} + +extract_tgz_clean(){ + local tgz="$1" dest="$2" + rm -rf "$dest"; mkdir -p "$dest" + tar -xzf "$tgz" -C "$dest" +} + +extract_zip_clean(){ + local zip="$1" dest="$2" + rm -rf "$dest"; mkdir -p "$dest" + if command -v unzip >/dev/null 2>&1; then + unzip -q "$zip" -d "$dest" + else + python3 - "$zip" "$dest" <<'PY' +import sys, zipfile +with zipfile.ZipFile(sys.argv[1]) as z: + z.extractall(sys.argv[2]) +PY + fi +} + +copy_contents(){ + local src="$1" dst="$2" + mkdir -p "$dst" + (cd "$src" && tar -cf - .) | (cd "$dst" && tar -xf -) +} + +remove_source_files(){ + shopt -s dotglob nullglob + for p in "$GA_DIR"/*; do + [[ "$(basename "$p")" == ".portable" ]] && continue + rm -rf "$p" + done + shopt -u dotglob nullglob +} + +# uv: GitHub release in GLOBAL mode, user's VPS otherwise +if [[ ! -x "$UV_EXE" || "$FORCE" == "1" ]]; then + if [[ "$GLOBAL" == "1" ]]; then + download "https://github.com/astral-sh/uv/releases/latest/download/$uv_file" "$UV_TGZ" + else + download "$VPS_BASE/uv/$uv_file" "$UV_TGZ" + fi + extract_tgz_clean "$UV_TGZ" "$UV_EXTRACT" + found_uv="$(find "$UV_EXTRACT" -type f -name uv | head -n 1 || true)" + [[ -n "$found_uv" ]] || die "uv not found in archive" + cp "$found_uv" "$UV_EXE" + chmod +x "$UV_EXE" +fi +ok "uv: $($UV_EXE --version)" + +export UV_PYTHON_INSTALL_DIR="$PORTABLE_ROOT/uv-python" +export UV_CACHE_DIR="$PORTABLE_ROOT/uv-cache" +if [[ "$GLOBAL" == "1" ]]; then + unset UV_DEFAULT_INDEX PIP_INDEX_URL +else + export UV_DEFAULT_INDEX="$MAINLAND_INDEX" + export PIP_INDEX_URL="$MAINLAND_INDEX" +fi +export PATH="$BIN:$PATH" + +say "Installing Python $PYTHON_VERSION via uv" +"$UV_EXE" python install "$PYTHON_VERSION" +PYTHON_EXE="$($UV_EXE python find "$PYTHON_VERSION")" +[[ -x "$PYTHON_EXE" ]] || die "uv installed Python but executable was not found" +ok "Python: $($PYTHON_EXE --version)" +PYTHON_DIR="$(dirname "$PYTHON_EXE")" +export PATH="$BIN:$PYTHON_DIR:$PATH" + +# git: macOS/Linux use system git. In mainland mode it is not required for source fetch. +GIT_EXE="" +if command -v git >/dev/null 2>&1; then + GIT_EXE="$(command -v git)" + ok "git: $($GIT_EXE --version)" +elif [[ "$GLOBAL" == "1" ]]; then + die "GLOBAL=1 requires git. Install git with your system package manager, then rerun." +else + say "git not found; continuing because mainland mode uses VPS zip. Install git later if needed." +fi + +# Fetch/update GenericAgent source. +if [[ "$GLOBAL" == "1" ]]; then + say "Cloning GenericAgent from GitHub" + if [[ -n "$(find "$GA_DIR" -mindepth 1 -maxdepth 1 ! -name .portable -print -quit)" ]]; then + [[ "$FORCE" == "1" ]] || die "Install dir contains files. Re-run with FORCE=1 to replace source while preserving portable tools." + remove_source_files + fi + TMP_CLONE="$CACHE/ga-clone" + rm -rf "$TMP_CLONE" + "$GIT_EXE" clone --depth 1 "$REPO_URL" "$TMP_CLONE" + copy_contents "$TMP_CLONE" "$GA_DIR" + rm -rf "$TMP_CLONE" +else + say "Downloading GenericAgent package from VPS" + download "$GA_ZIP_URL" "$GA_ZIP" + extract_zip_clean "$GA_ZIP" "$GA_EXTRACT" + SRC_DIR="$GA_EXTRACT/GenericAgent" + [[ -d "$SRC_DIR" ]] || SRC_DIR="$GA_EXTRACT" + remove_source_files + copy_contents "$SRC_DIR" "$GA_DIR" +fi +ok "GenericAgent source ready: $GA_DIR" + +# Install basic dependencies and project in editable mode into portable Python. +say "Installing GenericAgent dependencies via uv pip" +install_args=(pip install --python "$PYTHON_EXE" --break-system-packages) +if [[ "$GLOBAL" != "1" ]]; then install_args+=(--index-url "$MAINLAND_INDEX"); fi +install_args+=("${DEPS[@]}") +"$UV_EXE" "${install_args[@]}" + +if [[ -f "$GA_DIR/pyproject.toml" ]]; then + project_args=(pip install --python "$PYTHON_EXE" --break-system-packages) + if [[ "$GLOBAL" != "1" ]]; then project_args+=(--index-url "$MAINLAND_INDEX"); fi + project_args+=(-e "$GA_DIR") + "$UV_EXE" "${project_args[@]}" +fi + +# Try-install pywebview (optional UI). Failure is non-fatal. +say "Attempting to install pywebview (optional, failure is OK)" +webview_args=(pip install --python "$PYTHON_EXE" --break-system-packages) +if [[ "$GLOBAL" != "1" ]]; then webview_args+=(--index-url "$MAINLAND_INDEX"); fi +webview_args+=("pywebview>=4.0") +if "$UV_EXE" "${webview_args[@]}" 2>/dev/null; then + ok "pywebview installed successfully" +else + printf '\033[33m[warn]\033[0m pywebview install failed. This is optional.\n' + printf ' On Linux, pywebview requires system GTK/WebKit libraries.\n' + printf ' Install them first, e.g.:\n' + printf ' Debian/Ubuntu: sudo apt install python3-gi gir1.2-webkit2-4.1 libgirepository1.0-dev\n' + printf ' Fedora: sudo dnf install python3-gobject webkit2gtk4.1\n' + printf ' macOS: usually works out of the box (uses PyObjC)\n' + printf ' Then retry: uv pip install pywebview\n' +fi + +# Activation script: portable paths are intentionally before system PATH. +if [[ "$GLOBAL" == "1" ]]; then + cat > "$ENV_SH" < "$ENV_SH" < mykey.py" + fi +fi + +# Final banner +echo "" +if [[ "$GLOBAL" == "1" ]]; then + cat <
" + html.escape("\n".join(lines)) + "
" + +class _H(BaseHTTPRequestHandler): + def do_GET(self): + b = _page().encode("utf-8"); self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8"); self.end_headers(); self.wfile.write(b) + def do_POST(self): + global _TASK_SLUG, _PLANNED + if self.path != "/exec": self.send_response(404); self.end_headers(); return + n = int(self.headers.get("Content-Length", "0")); req = json.loads(self.rfile.read(n).decode("utf-8")) + out = io.StringIO(); err = io.StringIO(); rc = 0 + with _exec_lock, redirect_stdout(out), redirect_stderr(err): + _bind(req["rundir"]); _note("exec: " + req.get("path", " + + \ No newline at end of file diff --git a/assets/tmwd_cdp_bridge/popup.js b/assets/tmwd_cdp_bridge/popup.js new file mode 100644 index 0000000..730ed21 --- /dev/null +++ b/assets/tmwd_cdp_bridge/popup.js @@ -0,0 +1,24 @@ +document.addEventListener('DOMContentLoaded', () => { + const out = document.getElementById('out'); + const btn = document.getElementById('refresh'); + btn.addEventListener('click', fetchCookies); + fetchCookies(); +}); + +async function fetchCookies() { + const out = document.getElementById('out'); + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.url) { out.textContent = 'No active tab'; return; } + const resp = await chrome.runtime.sendMessage({ cmd: 'cookies', url: tab.url }); + if (!resp?.ok) { out.textContent = 'Error: ' + (resp?.error || 'unknown'); return; } + if (!resp.data.length) { out.textContent = '(no cookies)'; return; } + // 展示带标记 + out.textContent = resp.data.map(c => + `${c.name}=${c.value}` + (c.httpOnly ? ' [H]' : '') + (c.secure ? ' [S]' : '') + (c.partitionKey ? ' [P]' : '') + ).join('\n'); + // 自动复制 name=value; 格式到剪贴板 + const str = resp.data.map(c => `${c.name}=${c.value}`).join('; '); + await navigator.clipboard.writeText(str); + } catch (e) { out.textContent = 'Error: ' + e.message; } +} \ No newline at end of file diff --git a/assets/tools_schema.json b/assets/tools_schema.json new file mode 100644 index 0000000..27b00a3 --- /dev/null +++ b/assets/tools_schema.json @@ -0,0 +1,73 @@ +[ + {"type": "function", "function": { + "name": "code_run", + "description": "Code executor. Prefer python. Multi-call OK, use script param. Reply code block is executed if no script arg; prefer for single call to avoid escaping. No hardcoding bulk data", + "parameters": {"type": "object", "properties": { + "script": {"type": "string", "description": "[Mutually exclusive] NEVER use this param when use reply code block."}, + "type": {"type": "string", "enum": ["python", "powershell"], "description": "Code type", "default": "python"}, + "timeout": {"type": "integer", "description": "in seconds", "default": 60}, + "cwd": {"type": "string", "description": "Working directory, defaults to cwd"}, + "inline_eval": {"type": "boolean", "description": "DO NOT USE except explicitly specified."}}} + }}, + {"type": "function", "function": { + "name": "file_read", + "description": "Read file. Read before modify for latest context and line numbers", + "parameters": {"type": "object", "properties": { + "path": {"type": "string", "description": "Relative or absolute"}, + "start": {"type": "integer", "description": "Start line number (1-based)"}, + "count": {"type": "integer", "description": "Number of lines to read", "default": 200}, + "show_linenos": {"type": "boolean", "description": "Show line numbers", "default": true}}} + }}, + {"type": "function", "function": { + "name": "file_patch", + "description": "Replace unique old_content with new_content. Exact match required (whitespace/indentation). On failure, file_read to recheck", + "parameters": {"type": "object", "properties": { + "path": {"type": "string", "description": "File path"}, + "old_content": {"type": "string", "description": "Original text block to replace (must be unique)"}, + "new_content": {"type": "string", "description": "New content. Supports {{file:path:startLine:endLine}} to ref file lines, auto-expanded"}}} + }}, + {"type": "function", "function": { + "name": "file_write", + "description": "Create/overwrite/append files. HUGE edits ONLY. Supports {{file:path:startLine:endLine}}, auto-expanded", + "parameters": {"type": "object", "properties": { + "path": {"type": "string", "description": "File path"}, + "content": {"type": "string"}, + "mode": {"type": "string", "enum": ["overwrite", "append", "prepend"], "description": "Write mode", "default": "overwrite"}}} + }}, + {"type": "function", "function": { + "name": "web_scan", + "description": "Get simplified HTML and tab list. Removes hidden/floating/covered elements. Call after switching pages", + "parameters": {"type": "object", "properties": { + "tabs_only": {"type": "boolean", "description": "Show tab list only, no HTML"}, + "switch_tab_id": {"type": "string", "description": "[Optional] Tab ID to switch to"}, + "text_only": {"type": "boolean", "description": "Plain text only, no HTML"}}} + }}, + {"type": "function", "function": { + "name": "web_execute_js", + "description": "Execute JS. Multi-call OK with different switch_tab_id. No guessing. Act accurately to reduce web_scan calls. Execute JS in ```javascript blocks if no script arg, prefer to avoid escaping", + "parameters": {"type": "object", "properties": { + "script": {"type": "string", "description": "[Mutually exclusive] JS code or script path. NEVER use this param when use reply code block"}, + "save_to_file": {"type": "string", "description": "file path; **only** for long result"}, + "no_monitor": {"type": "boolean", "description": "Skip page change monitoring, saves 2-3s. Only for reads, not for page actions"}, + "switch_tab_id": {"type": "string", "description": "[Optional] Tab ID to switch to before executing"}}} + }}, + {"type": "function", "function": { + "name": "update_working_checkpoint", + "description": "Short-term working notepad, auto-injected each turn to prevent info loss in long tasks. Call during early/mid stages, not at end. When: (1) after reading SOP, store user needs & key constraints (skip for simple 1-2 step tasks); (2) before subtask switch or context flush; (3) after repeated failures, re-read SOP and must store new findings; (4) on new task, update content, clear old progress but keep valid constraints.\n\nDon't call: simple tasks (1-2 steps), task completed (use long-term memory tool)", + "parameters": {"type": "object", "properties": { + "key_info": {"type": "string", "description": "Replaces current notepad (<200 tokens). Incremental update: review existing, keep valid, add/remove/modify. Store: pitfalls, user requirements, key params/findings, file paths, progress, next steps. Don't store: ephemeral info, obvious context, old task info when user switched tasks. Prefer over-updating over losing key info"}, + "related_sop": {"type": "string", "description": "Related SOP names, tips for further re-read"}}} + }}, + {"type": "function", "function": { + "name": "ask_user", + "description": "Interrupt task to ask user when needing decisions, extra info, or facing unresolvable blockers. Multiple questions should in one call", + "parameters": {"type": "object", "properties": { + "question": {"type": "string", "description": "For multiple questions, one per line (contain candidates)"}, + "candidates": {"type": "array", "items": {"type": "string"}, "description": "Optional quick-select choices for a single question only. Leave empty for multi-questions"}}} + }}, + {"type": "function", "function": { + "name": "start_long_term_update", + "description": "Start distilling long-term memory. Call when discovering info worth remembering (env facts/user prefs/lessons learned). Skip if memory already updated or in autonomous flow. Must call when a task that took 15+ turns is completed", + "parameters": {"type": "object", "properties": {}}} + } +] \ No newline at end of file diff --git a/assets/tools_schema_cn.json b/assets/tools_schema_cn.json new file mode 100644 index 0000000..98fbc21 --- /dev/null +++ b/assets/tools_schema_cn.json @@ -0,0 +1,73 @@ +[ + {"type": "function", "function": { + "name": "code_run", + "description": "代码执行器。优先使用python。支持Multi-call,并行时用script参数。无script参数时正文代码块会被执行,单次调用优先使用以免转义。禁硬编码大量数据", + "parameters": {"type": "object", "properties": { + "script": {"type": "string", "description": "[Optional] 要执行的代码。为免转义建议留空,改用正文代码块(与此参数互斥)"}, + "type": {"type": "string", "enum": ["python", "powershell"], "description": "代码类型", "default": "python"}, + "timeout": {"type": "integer", "description": "执行超时时间(秒)", "default": 60}, + "cwd": {"type": "string", "description": "工作目录,默认为当前工作目录"}, + "inline_eval": {"type": "boolean", "description": "不允许使用除非明确要求"}}} + }}, + {"type": "function", "function": { + "name": "file_read", + "description": "读取文件内容。建议在修改文件前先读取,以确保获取最新的上下文和行号。支持分页读取或关键字搜索", + "parameters": {"type": "object", "properties": { + "path": {"type": "string", "description": "文件相对或绝对路径"}, + "start": {"type": "integer", "description": "起始行号(从 1 开始)"}, + "count": {"type": "integer", "description": "读取的行数", "default": 200}, + "show_linenos": {"type": "boolean", "description": "是否显示行号,建议开启以辅助 file_patch 定位", "default": true}}} + }}, + {"type": "function", "function": { + "name": "file_patch", + "description": "精细化局部文件修改。在文件中寻找唯一的 old_content 块并替换为 new_content。要求 old_content 必须在文件中唯一存在,且空格、缩进、换行必须与原文件完全一致。如果匹配失败,请使用 file_read 重新确认文件内容", + "parameters": {"type": "object", "properties": { + "path": {"type": "string", "description": "文件路径"}, + "old_content": {"type": "string", "description": "文件中需要被替换的原始文本块(需确保唯一性)"}, + "new_content": {"type": "string", "description": "替换后的新文本内容。支持 {{file:路径:起始行:结束行}} 语法引用文件内容,写入前自动展开"}}} + }}, + {"type": "function", "function": { + "name": "file_write", + "description": "用于文件的新建、全量覆盖或追加写入。对于精细的代码修改,应优先使用 file_patch。写入内容支持 {{file:路径:起始行:结束行}} 语法引用文件片段,写入前自动展开", + "parameters": {"type": "object", "properties": { + "path": {"type": "string", "description": "文件路径"}, + "content": {"type": "string"}, + "mode": {"type": "string", "enum": ["overwrite", "append", "prepend"], "description": "写入模式覆盖、追加或在开头追加", "default": "overwrite"}}} + }}, + {"type": "function", "function": { + "name": "web_scan", + "description": "获取当前页面的简化HTML内容和标签页列表。会移除隐藏/浮动/被遮盖的元素。切换页面后一般应先调用查看", + "parameters": {"type": "object", "properties": { + "tabs_only": {"type": "boolean", "description": "仅返回标签页列表和当前标签信息,不获取HTML内容"}, + "switch_tab_id": {"type": "string", "description": "可选的标签页 ID。如果提供,系统将在扫描前切换到该标签页"}, + "text_only": {"type": "boolean", "description": "只要纯文本不要HTML"}}} + }}, + {"type": "function", "function": { + "name": "web_execute_js", + "description": "执行JS。支持Multi-call,用不同switch_tab_id并行操作多标签页。禁止猜测,准确操作以减少 web_scan 调用。无script参数时执行正文 ```javascript 块,以免转义", + "parameters": {"type": "object", "properties": { + "script": {"type": "string", "description": "[Optional] JS代码或路径。为免转义建议留空,改用正文代码块(与此参数互斥)"}, + "save_to_file": {"type": "string", "description": "结果存文件,适合返回值较长时"}, + "no_monitor": {"type": "boolean", "description": "跳过页面变更监控,省2-3秒。仅在纯读取信息时设置,页面操作时不要设置"}, + "switch_tab_id": {"type": "string", "description": "可选的标签页 ID,切换到该标签页执行"}}} + }}, + {"type": "function", "function": { + "name": "update_working_checkpoint", + "description": "短期工作便签,每轮自动注入上下文,防长任务信息丢失。前中期调用,非结束时。何时调用:(1)任务开始读SOP后,存用户需求和关键约束/参数(简单1-2步任务除外);(2)子任务切换或上下文即将被冲刷前;(3)多次重试失败后,重读SOP并必须调用存储新发现;(4)切换新任务时更新内容,清旧进度但保留仍有效的约束。\n\n何时不调用:简单任务(1-2步且无严重约束)、任务已完成时(应当用长期结算工具)", + "parameters": {"type": "object", "properties": { + "key_info": {"type": "string", "description": "替换当前便签(<200 tokens)。增量更新:先回顾现有内容,保留仍有效的,再增删改。存:要避的坑、用户原始需求、关键参数/发现、文件路径、当前进度、下一步计划。不存:马上要用用完即丢的、上下文中显而易见的、用户已换全新任务时的旧任务信息。宁多更新不丢关键"}, + "related_sop": {"type": "string", "description": "相关sop名称,可以多个,必要时需要再读"}}} + }}, + {"type": "function", "function": { + "name": "ask_user", + "description": "当需要用户决策、提供额外信息或遇到无法自动解决的阻碍时,调用此工具中断任务并提问。多题时应一次调用", + "parameters": {"type": "object", "properties": { + "question": {"type": "string", "description": "多题时每题*独占*一行(带选项)"}, + "candidates": {"type": "array", "items": {"type": "string"}, "description": "仅单题时可用。多题时留空"}}} + }}, + {"type": "function", "function": { + "name": "start_long_term_update", + "description": "准备开始提炼记忆。发现值得长期记忆的信息(环境事实/用户偏好/避坑经验)时调用此工具。已记忆更新或在自主流程内时无需调用。超15轮完成的任务必须调用以沉淀经验", + "parameters": {"type": "object", "properties": {}}} + } +] \ No newline at end of file diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..eec10a5 --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,298 @@ +# 🚀 新手上手指南 + +> 完全没接触过编程也没关系,跟着做就行。Mac / Windows 都适用。 +> +> 如果你已经有 Python 环境,直接跳到[第 2 步](#2-配置-api-key)。 + +--- + +## 1. 安装 Python + +### Mac + +打开「终端」(启动台搜索 "终端" 或 "Terminal"),粘贴这行命令然后回车: + +```bash +brew install python +``` + +如果提示 `brew: command not found`,说明还没装 Homebrew,先粘贴这行: + +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +``` + +装完后再执行 `brew install python`。 + +### Windows + +1. 打开 [python.org/downloads](https://www.python.org/downloads/),点黄色大按钮下载 +2. 运行安装包,**底部的 "Add Python to PATH" 一定要勾上** +3. 点 "Install Now" + +### 验证 + +终端 / 命令提示符里输入: + +```bash +python3 --version +``` + +看到 `Python 3.x.x` 就 OK。Windows 上也可以试 `python --version`。 + +> ⚠️ **版本提示**:推荐 **Python 3.11 或 3.12**。不要使用 3.14(与 pywebview 等依赖不兼容)。 + +--- + +## 2. 配置 API Key + +### 下载项目 + +最方便的方式是 **一键安装**(自带隔离 Python 环境 + Git + 桌面端): + +**Windows PowerShell** + +```powershell +powershell -ExecutionPolicy Bypass -c "irm http://fudankw.cn:9000/files/ga_install.ps1 | iex" +``` + +**Linux / macOS** + +```bash +curl -fsSL http://fudankw.cn:9000/files/ga_install.sh | bash +``` + +或者手动 clone(开发者): + +```bash +git clone https://github.com/lsdefine/GenericAgent.git +cd GenericAgent +uv venv && uv pip install -e ".[ui]" +``` + +也可以走最朴素的 ZIP:[GitHub 仓库页面](https://github.com/lsdefine/GenericAgent) → 点绿色 **Code** → **Download ZIP** → 解压到喜欢的位置。 + +> 💡 **让 Claude / Codex 等 Agent 帮你装**:把下面这条 curl 丢给它,它会按官方指南替你完成安装: +> ```bash +> curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/main/docs/installation_zh.md +> ``` +> +> 📖 平台差异、排障、升级流程见 [`docs/installation_zh.md`](installation_zh.md)。 + +### 创建配置文件 + +进入项目文件夹,把 `mykey_template.py` 复制一份,重命名为 `mykey.py`。 + +用任意文本编辑器打开 `mykey.py`,填入你的 API 信息。**选一种填就行**,不用的配置删掉或留着不管都行。 + +> 💡 也可以运行交互式向导 `python assets/configure_mykey.py`,按提示选择厂商、填入 Key 即可自动生成 `mykey.py`。 + +### 配置示例 + +**推荐首选:Claude 原生协议**: + +```python +# 变量名同时含 'native' 和 'claude' → NativeClaudeSession(API 原生工具字段) +native_claude_config = { + 'name': 'claude', # /llms 显示名 & mixin 引用名 + 'apikey': 'sk-xxx', # sk-ant- 走 x-api-key;其它走 Bearer + 'apibase': 'https://api.anthropic.com', # 官方直连;反代渠道填对应地址 + 'model': 'claude-opus-4-7', # [1m] 后缀触发 1M 上下文 beta + # 'fake_cc_system_prompt': True, # CC switch / 反代渠道必须置 True +} +``` + +**也支持:OpenAI 原生协议**: + +```python +# 变量名同时含 'native' 和 'oai' → NativeOAISession +native_oai_config = { + 'name': 'gpt', # /llms 显示名 & mixin 引用名 + 'apikey': 'sk-xxx', + 'apibase': 'https://api.openai.com/v1', # 自动补 /v1/chat/completions + 'model': 'gpt-5.5', +} +``` + +**进阶:Mixin 故障转移**(多 session 自动切换,最稳的玩法): + +```python +# llm_nos 按优先级排列;首项失败按指数退避切下一项 +mixin_config = { + 'llm_nos': ['claude', 'gpt'], # 与上面 native_* 的 name 字段对应 + 'max_retries': 10, + 'base_delay': 0.5, +} +``` + +> 💡 完整字段说明(`thinking_type` / `reasoning_effort` / `context_win` / `proxy` / Zhipu / MiniMax / Kimi / OpenRouter 等渠道示例)见 `mykey_template.py` 顶部注释。 + +### 关键规则 + +**变量命名决定 Session 类型**(不是模型名决定的): + +| 变量名包含 | 触发的 Session | 工具协议 | 适用场景 | +|-----------|---------------|---------|---------| +| `native` + `claude` | NativeClaudeSession | API 原生 tool 字段 | **推荐首选** — Claude 原生协议 | +| `native` + `oai` | NativeOAISession | API 原生 tool 字段 | GPT/o 系列、OAI 兼容渠道 | +| `mixin` | MixinSession | 多 session 故障转移 | 最稳;要求被引用 session 全为 native | +| `claude`(不含 `native`) | ClaudeSession | 文本协议工具 | **deprecated**,后续版本可能移除 | +| `oai`(不含 `native`) | LLMSession | 文本协议工具 | **deprecated**,后续版本可能移除 | + +**`apibase` 填写规则**(会自动拼接端点路径): + +| 你填的内容 | 系统行为 | +|-----------|---------| +| `http://host:2001` | 自动补 `/v1/chat/completions` | +| `http://host:2001/v1` | 自动补 `/chat/completions` | +| `http://host:2001/v1/chat/completions` | 直接使用,不拼接 | + +--- + +## 3. 初次启动 + +终端里进入项目文件夹,运行: + +```bash +cd 你的解压路径 +python3 agentmain.py +``` + +这就是**命令行模式**,已经可以用了。你会看到一个输入提示符,直接打字发送任务即可。 + +试试你的第一个任务: + +``` +帮我在桌面创建一个 hello.txt,内容是 Hello World +``` + +> 💡 Windows 上如果 `python3` 不识别,换成 `python agentmain.py`。 + +--- + +## 4. 让 Agent 自己装依赖 + +Agent 启动后,只需要一句话,它就会自己搞定所有依赖: + +``` +请查看你的代码,安装所有用得上的 python 依赖 +``` + +Agent 会自己读代码、找出需要的包、全部装好。 + +> ⚠️ 如果遇到网络问题导致 Agent 无法调用 API,可能需要先手动装一个包: +> ```bash +> pip install requests +> ``` + +### 升级到图形界面 + +依赖装完后,可以选择适合你的前端: + +| 前端 | 启动命令 | 说明 | +|------|---------|------| +| **桌面端** | 双击 `frontends/GenericAgent.exe`(Windows 一键安装自带) | 真原生窗口,零终端依赖 | +| **TUI v3** | `python frontends/tui_v3.py` | 基于块的滚屏回看、resize 重排、每终端独立配色,跨终端体验一致 | +| **TUI v2** | `python frontends/tuiapp_v2.py` | Textual 键盘驱动界面,图片粘贴折叠、`/llm`/`/export`/`/continue` 选择器 | +| **Streamlit / 悬浮窗** | `python launch.pyw` | 浏览器中打开的 Streamlit UI,附带桌面悬浮窗 | + +> 💡 Windows 下推荐用 **Git Bash** 跑 TUI;PowerShell / cmd 对 Unicode 和键位支持较弱。仍异常时请直接告诉 Agent:「参考 Claude Code 在 Windows 终端的最佳配置帮我把 TUI 修一遍」。 + +### 可选:让 Agent 帮你做的事 + +``` +请帮我建立 git 连接,方便以后更新代码 +``` + +Agent 会自动配好。如果你电脑上没有 Git,它也会帮你下载 portable 版。 + +``` +请帮我在桌面创建一个 launch.pyw 的快捷方式 +``` + +这样以后双击桌面图标就能启动,不用再开终端了。 + +--- + +## 5. 能力解锁 + +环境跑起来之后,你可以逐步解锁更多能力。每一项都只需要**对 Agent 说一句话**: + +### 基础能力 + +| 能力 | 对 Agent 说 | 说明 | +|------|-----------|------| +| **PowerShell 脚本执行** | `帮我解锁当前用户的 PowerShell ps1 执行权限` | Windows 默认禁止运行 .ps1 脚本 | +| **全局文件搜索** | `安装并配置 Everything 命令行工具进 PATH` | 毫秒级全盘文件搜索 | + +### 浏览器自动化 + +| 能力 | 对 Agent 说 | 说明 | +|------|-----------|------| +| **Web 工具解锁** | `执行 web setup sop,解锁 web 工具` | 注入浏览器插件,使 Agent 能直接操控网页 | + +解锁后,Agent 可以在**保留你登录态**的真实浏览器中操作: + +``` +打开淘宝,搜索 iPhone 16,按价格排序 +去 B 站,查看我最近看过的历史视频 +``` + +### 进阶能力 + +| 能力 | 对 Agent 说 | 说明 | +|------|-----------|------| +| **OCR** | `用rapidocr配置你的ocr能力并存入记忆` | 让 Agent 能"看到"屏幕文字 | +| **屏幕视觉** | `仿造你的llmcore,写个调用vision的能力并存入记忆` | 让 Agent 能"看到"屏幕内容 | +| **移动端控制** | `配置 ADB 环境,准备连接安卓设备` | 通过 USB/WiFi 控制 Android 手机 | + +### 聊天平台接入(可选) + +接入后可以随时随地通过手机给电脑上的 Agent 发指令。 + +对 Agent 说:`看你的代码,帮我配置 XX 平台的机器人接入` + +支持的平台:**微信个人Bot** / QQ / 飞书 / 企业微信 / 钉钉 / Telegram + +> Agent 会自动读取代码、引导你完成配置。 + +### 高级模式 + +以下模式全部**自文档化**——不用查手册,直接问 Agent 即可: + +| 模式 | 对 Agent 说 | +|------|------------| +| **Reflect(反射)** | `查看你的代码,告诉我你的 reflect 模式怎么启用` | +| **计划任务** | `查看你的代码,告诉我你的计划任务模式怎么启用` | +| **Plan(规划)** | `查看你的代码,告诉我你的 plan 模式怎么启用` | +| **SubAgent(子代理)** | `查看你的代码,告诉我你的 subagent 模式怎么启用` | +| **自主探索** | `查看你的代码,告诉我你的自主探索模式怎么启用` | +| **Goal** | `查看你的代码,告诉我 goal 模式怎么启用` | +| **Goal Hive(多 worker 协作)** | `查看你的代码,告诉我 goal hive 模式怎么启用` | +| **Conductor(多 subagent 编排)** | `查看你的代码,告诉我 conductor 模式怎么启用` | +| **Morphling(吞噬外部项目)** | `查看你的代码,告诉我 morphling 模式怎么启用` | + +> 💡 这就是 GenericAgent 的核心设计理念:**代码即文档**。Agent 能读懂自己的源码,所以任何功能你都可以直接问它。 + +--- + +## 💡 使用越久越强 + +GenericAgent 不预设技能,而是**靠使用进化**。每完成一个新任务,它会自动将执行路径固化为 Skill,下次遇到类似任务直接调用。 + +你不需要管理这些 Skill,Agent 会自动处理。使用时间越长,积累的技能越多,最终形成一棵完全属于你的专属技能树。 + +> 💡 如果你觉得某些重要信息 Agent 没有记住,可以直接告诉它:`把这个记到你的记忆里`,它会主动记忆。 + +**其他 Claw 的 Skill 也可以直接复用:** + +- 让 Agent 搜索:`帮我找个做 XXX 的 skill` → 完成后 → `加入你的记忆中` +- 直接指定来源:`访问 XXX 文件夹/URL,按照这个 skill 做 XXX` + +**保持更新:** + +对 Agent 说:`git 更新你的代码,然后看看 commit 有什么新功能` + +> Agent 会自动 pull 最新代码并解读 commit log,告诉你新增了什么能力。 + +> 更多细节请参阅 [README.md](../README.md) 或 [详细版图文教程](https://my.feishu.cn/wiki/CGrDw0T76iNFuskmwxdcWrpinPb)。 diff --git a/docs/SETUP_FEISHU.md b/docs/SETUP_FEISHU.md new file mode 100644 index 0000000..da2ca05 --- /dev/null +++ b/docs/SETUP_FEISHU.md @@ -0,0 +1,301 @@ +# 飞书 Agent 配置指南 + +> 让你的个人电脑变成飞书机器人的大脑,随时随地通过飞书对话控制你的电脑。 + +--- + +## 📋 目录 + +1. [前置条件](#前置条件) +2. [方案选择](#方案选择) +3. [企业用户配置](#企业用户配置) +4. [个人用户配置](#个人用户配置) +5. [项目配置](#项目配置) +6. [运行与测试](#运行与测试) +7. [常见问题](#常见问题) + +--- + +## 前置条件 + +### 必需环境 + +- Python 3.8+ +- 本项目完整代码 +- LLM API 密钥(Claude/OpenAI 等,已在 `llmcore/mykeys` 中配置) + +### 安装依赖 + +```bash +pip install lark-oapi +``` + +--- + +## 方案选择 + +| 你的情况 | 推荐方案 | 预计耗时 | +| ------------------ | -------------------------- | --------- | +| 公司已有飞书企业版 | [企业用户配置](#企业用户配置) | 5-10分钟 | +| 个人用户/学习测试 | [个人用户配置](#个人用户配置) | 10-15分钟 | + +--- + +## 企业用户配置 + +> 适用于:你的公司使用飞书,你有权限创建应用或联系管理员审批 + +### 步骤 1:创建应用 + +1. 访问 [飞书开放平台](https://open.feishu.cn/) +2. 登录你的企业飞书账号 +3. 点击右上角「创建应用」→「企业自建应用」 +4. 填写应用信息: + - 应用名称:`我的Agent助手`(可自定义) + - 应用描述:`个人AI助手` + - 应用图标:可选 + +### 步骤 2:添加机器人能力 + +1. 进入应用详情页 +2. 左侧菜单选择「添加应用能力」 +3. 找到「机器人」,点击「添加」 +4. 配置机器人信息(可保持默认) + +### 步骤 3:配置权限 + +1. 左侧菜单「权限管理」→「API 权限」 +2. 搜索并开通以下权限: + - `im:message` - 获取与发送单聊、群组消息 + - `im:message:send_as_bot` - 以应用身份发送消息 + - `contact:user.id:readonly` - 获取用户 ID + +### 步骤 4:获取凭证 + +1. 左侧菜单「凭证与基础信息」 +2. 记录以下信息: + - **App ID**:`cli_xxxxxxxx` + - **App Secret**:`xxxxxxxxxxxxxxxx` + +### 步骤 5:发布应用 + +1. 左侧菜单「版本管理与发布」 +2. 点击「创建版本」 +3. 填写版本信息,提交审核 +4. **联系企业管理员审批**(或自己是管理员直接审批) + +### 步骤 6:获取你的 Open ID + +1. 应用审批通过后,在飞书中搜索你的机器人 +2. 给机器人发送任意消息 +3. 运行以下代码获取你的 Open ID: + +```python +# 临时运行一次,获取 open_id +import lark_oapi as lark +from lark_oapi.api.im.v1 import * + +client = lark.Client.builder().app_id("你的APP_ID").app_secret("你的APP_SECRET").build() + +# 监听消息,打印发送者的 open_id +def handle(data): + print(f"你的 Open ID: {data.event.sender.sender_id.open_id}") + +# ... 或者查看 frontends/fsapp.py 运行时的日志输出 +``` + +--- + +## 个人用户配置 + +> 适用于:没有企业飞书账号,想个人测试使用 + +### 步骤 1:创建测试企业 + +1. 访问 [飞书开放平台](https://open.feishu.cn/) +2. 使用个人手机号注册/登录 +3. 点击右上角头像 →「创建测试企业」 +4. 填写企业名称(如:`我的测试工作区`) +5. 创建完成后,你就是这个测试企业的**管理员** + +### 步骤 2:创建应用 + +> 与企业用户步骤相同 + +1. 点击「创建应用」→「企业自建应用」 +2. 填写应用信息 + +### 步骤 3:添加机器人能力 + +1. 进入应用详情页 +2. 「添加应用能力」→「机器人」→「添加」 + +### 步骤 4:配置权限 + +1. 「权限管理」→「API 权限」 +2. 开通权限: + - `im:message` + - `im:message:send_as_bot` + - `contact:user.id:readonly` + +### 步骤 5:获取凭证 + +1. 「凭证与基础信息」 +2. 复制 **App ID** 和 **App Secret** + +### 步骤 6:发布应用(测试企业可自审批) + +1. 「版本管理与发布」→「创建版本」 +2. 提交后,进入 [飞书管理后台](https://feishu.cn/admin) +3. 「工作台」→「应用审核」→ 通过你的应用 + +### 步骤 7:在飞书客户端使用 + +1. 下载 [飞书客户端](https://www.feishu.cn/download) +2. 登录你的测试企业账号 +3. 搜索你创建的机器人名称 +4. 开始对话! + +--- + +## 项目配置 + +### 配置飞书凭证 + +编辑项目根目录的 `mykey.py`,添加: + +```python +# 飞书应用凭证 +fs_app_id = "cli_xxxxxxxxxxxxxxxx" # 替换为你的 App ID +fs_app_secret = "xxxxxxxxxxxxxxxx" # 替换为你的 App Secret + +# 允许使用的用户 Open ID 列表(可选,留空则允许所有人) +fs_allowed_users = [ + "ou_xxxxxxxxxxxxxxxxxxxxxxxx", # 你的 Open ID +] +``` + +### 确认 LLM 配置 + +确保 `llmcore/mykeys` 中已配置 LLM API 密钥: + +```python +# 示例:Claude API +claude_config = { + 'apikey': 'sk-ant-xxxxx', + 'apibase': 'https://api.anthropic.com', + 'model': 'claude-sonnet-4-20250514' +} +``` + +--- + +## 运行与测试 + +### 启动服务 + +```bash +cd /path/to/pc-agent-loop +python frontends/fsapp.py +``` + +### 预期输出 + +``` +================================================== +飞书 Agent 已启动(长连接模式) +App ID: cli_xxxxxxxxxxxxxxxx +等待消息... +================================================== +``` + +### 测试对话 + +1. 打开飞书客户端 +2. 找到你的机器人 +3. 发送:`你好` +4. 等待回复(首次可能需要几秒) + +--- + +## 可用命令 + +在与机器人对话时,可以使用以下特殊命令: + +| 命令 | 说明 | +| ---- | ---- | +| `/new` | 开始新对话,清除当前上下文 | +| `/stop` | 中止当前正在执行的任务 | +| `/restore <关键词>` | 恢复之前的对话上下文(根据关键词搜索历史记录) | + +### 命令示例 + +``` +/new # 清空对话,重新开始 +/stop # 停止正在运行的任务 +/restore 昨天的任务 # 恢复包含"昨天的任务"关键词的历史对话 +``` + +### 消息显示说明 + +- ⏳ 表示任务正在执行中 +- 消息会实时更新,无需等待完成 +- 超长回复会自动分段发送 + +--- + +## 常见问题 + +### Q: 提示「应用未发布」或「无权限」 + +**A:** 确保应用已发布且管理员已审批。测试企业用户需要在管理后台手动审批。 + +### Q: 发送消息后没有回复 + +**A:** 检查: + +1. `frontends/fsapp.py` 是否在运行 +2. 终端是否有错误日志 +3. LLM API 密钥是否配置正确 + +### Q: 提示「invalid app_id」 + +**A:** 检查 `mykey.py` 中的 `fs_app_id` 是否正确复制(包含 `cli_` 前缀) + +### Q: 如何获取自己的 Open ID? + +**A:** 运行 `frontends/fsapp.py` 后给机器人发消息,查看终端日志中的 `open_id` + +### Q: 能否多人同时使用? + +**A:** 不能。一个应用只能有一个长连接,连接到一台电脑。每个人需要创建自己的应用。 + +--- + +## 架构说明 + +``` +你的飞书 ←→ 飞书云 ←→ 长连接 ←→ frontends/fsapp.py ←→ Agent ←→ 你的电脑 + ↑ + 运行在你电脑上 +``` + +- 消息通过飞书云转发到你电脑上运行的 `frontends/fsapp.py` +- Agent 处理请求后,通过飞书 API 回复消息 +- **你的电脑必须保持运行** `frontends/fsapp.py` 才能响应消息 + +--- + +## 下一步 + +- 自定义 Agent 行为:编辑 `assets/sys_prompt.txt` +- 添加新工具:编辑 `assets/tools_schema.json` +- 查看日志:运行时观察终端输出 + +--- + +*文档版本:v1.1 | 更新日期:2026-03-07* + +**v1.1 更新内容:** +- 新增「可用命令」章节(/new, /stop, /restore) +- 新增消息显示说明(⏳ 进行中标记、实时更新等) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..dab7227 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,323 @@ +# Installation Guide + +This is the detailed installation guide for **GenericAgent**. + +Two audiences: + +- **[For Humans](#for-humans)** — you are installing GA for yourself. +- **[For LLM Agents](#for-llm-agents)** — you are a coding agent such as Claude Code, or Codex installing GA for a human user. Read that section first so you do not guess. + +> The shortest install commands live in the main [README](../README.md#-quick-start). This guide adds platform notes, key setup, verification, troubleshooting, and agent-safe rules. + +--- + +## For Humans + +### Prerequisites + +| Requirement | Notes | +|---|---| +| **OS** | Windows 10/11, macOS 12+, or a modern Linux distribution. | +| **Python** | Use **Python 3.11 or 3.12**. **Do not use Python 3.14** — it is incompatible with `pywebview` and a few GA dependencies. The one-line installer ships an isolated Python environment, so manual Python setup is usually unnecessary. | +| **Git** | Recommended for updates and self-evolution. | +| **LLM API key** | GA speaks two native protocols: **OpenAI-compatible** APIs and **Anthropic Claude native** APIs. GPT-family models, Claude, Kimi, MiniMax, DeepSeek, GLM, Qwen, Gemini through OAI-compatible gateways, and similar providers can be configured through `mykey.py`. | + +### Method 1: One-line install (recommended) + +This is the easiest path. It prepares an isolated runtime, downloads GenericAgent, installs the core dependencies, and gives you a ready-to-run local project tree. + +**Windows PowerShell** + +```powershell +powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm http://fudankw.cn:9000/files/ga_install.ps1 | iex" +``` + +**Linux / macOS** + +```bash +GLOBAL=1 bash -c "$(curl -fsSL http://fudankw.cn:9000/files/ga_install.sh)" +``` + +After installation, launch the desktop app from: + +```text +frontends/GenericAgent.exe +``` + +Or run from the project directory: + +```bash +python launch.pyw +``` + +> GenericAgent is meant to grow its environment through the Agent itself, not by pre-installing every possible package. Start small, then let GA install task-specific tools when it actually needs them. + +#### Custom install location + +```bash +INSTALL_DIR="$HOME/work/GenericAgent" GLOBAL=1 bash -c "$(curl -fsSL http://fudankw.cn:9000/files/ga_install.sh)" +``` + +```powershell +$env:INSTALL_DIR="C:\dev\GenericAgent"; powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm http://fudankw.cn:9000/files/ga_install.ps1 | iex" +``` + +#### Force reinstall + +Use this only when you know you want to refresh the installed files. Back up `mykey.py`, `memory/`, `skills/`, and any local work first. + +```bash +FORCE=1 GLOBAL=1 bash -c "$(curl -fsSL http://fudankw.cn:9000/files/ga_install.sh)" +``` + +### Method 2: Python install (for developers) + +Use this when you want a normal editable checkout. + +```bash +git clone https://github.com/lsdefine/GenericAgent.git +cd GenericAgent +uv venv +uv pip install -e ".[ui]" # Core + UI dependencies +cp mykey_template.py mykey.py # Fill in your LLM API key +python launch.pyw +``` + +Full guide: [GETTING_STARTED.md](GETTING_STARTED.md) + +### Configure your LLM key + +1. Open the installed `GenericAgent` directory. +2. If `mykey.py` does not exist, copy it from `mykey_template.py`. +3. Fill in one provider. Do **not** paste example keys as real keys. +4. If you are unsure about the fields, read the comments in `mykey_template.py` first. + +GA supports: + +- **OpenAI-compatible** endpoints — Chat Completions / Responses shaped APIs. +- **Anthropic Claude native** — Claude Messages API. + +Optional helper: + +```bash +python assets/configure_mykey.py +``` + +### Frontends + +#### Desktop App + +For one-line installs on Windows, double-click: + +```text +frontends/GenericAgent.exe +``` + +#### Terminal UI + +A lightweight keyboard-driven interface built on [Textual](https://github.com/Textualize/textual). It supports multiple concurrent sessions and real-time streaming. + +```bash +python frontends/tuiapp_v2.py +``` + +#### Streamlit UI + +```bash +python launch.pyw +``` + +### Verify the install + +From the GenericAgent directory: + +```bash +python -c "import agent_loop; print('OK')" +git rev-parse --short HEAD +``` + +Then launch at least one frontend: + +```bash +python launch.pyw +# or +python frontends/tuiapp_v2.py +``` + +### Common gotchas + +#### Python 3.14 is not supported + +If your system `python --version` reports 3.14, do not use it for GA. Use the one-line installer, or create a Python 3.11 / 3.12 environment with `uv`. + +#### `ga` command conflict + +Some systems already use `ga` for another tool. Check first: + +```bash +type ga +``` + +If it resolves to something unexpected, do not rely on the shortcut. Run GA from the install directory with `python launch.pyw` or `python frontends/tuiapp_v2.py`. + +#### Windows TUI rendering issues + +TUI rendering on Windows depends on terminal, font, and `textual` version. + +1. Upgrade Textual first: `pip install -U textual`. +2. Prefer **Git Bash on Windows** over classic PowerShell / cmd when Unicode or key bindings look broken. +3. If it still looks wrong, ask GA itself to repair the terminal compatibility. Example prompt: + > *"My experience using `frontends/tuiapp_v2.py` in PowerShell / cmd / Git Bash on Windows is very poor — lots of incompatibility. Please refer to Claude Code's best practices for the Windows terminal and fix all font and rendering incompatibilities."* + +#### `pywebview` install fails on Linux + +Desktop mode depends on GTK / WebKit packages. On Debian / Ubuntu: + +```bash +sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev +pip install pywebview +``` + +On headless servers, prefer the TUI or run Streamlit in browser mode instead of desktop mode. + +### Update an existing install + +Do not delete a working GA directory just to update it. It may contain the user's `mykey.py`, memory, skills, and local work. + +```bash +cd ~/GenericAgent +git fetch +git pull +uv pip install -e ".[ui]" +``` + +If `git pull` reports conflicts: + +- For `mykey.py`, `memory/`, `skills/`, `temp/`, and user-created artifacts: prefer the local copy. +- For core code such as `agent*.py`, `frontends/*`, `reflect/*`, and `tools/*`: usually prefer upstream unless the user says otherwise. +- Resolve explicitly, then `git add` and commit the merge. + +--- + +## For LLM Agents + +> **IMPORTANT: fetch this file with `curl`, not a summarized web reader.** Summaries often drop flags, command quoting, and safety rules. +> +> ```bash +> curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/main/docs/installation.md +> ``` + +You are installing **GenericAgent** for a human user. Be direct, verify each step, and avoid destructive cleanup unless the user explicitly authorizes it. + +### Step 0: Inspect before acting + +Check: + +1. OS: `uname -a`, `$env:OS`, or `python -c "import platform; print(platform.system())"`. +2. Existing install: `~/GenericAgent/launch.pyw`, `%USERPROFILE%\GenericAgent\launch.pyw`, or a `.git` directory. +3. Python version: must be 3.11 or 3.12 if you use the developer path. +4. Git availability: `git --version`. +5. API key readiness: ask the user if no key is available. Never invent one. + +### Step 1: Choose the install path + +Default to the one-line installer unless the user already has a checkout. + +**Windows PowerShell** + +```powershell +powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm http://fudankw.cn:9000/files/ga_install.ps1 | iex" +``` + +**Linux / macOS** + +```bash +GLOBAL=1 bash -c "$(curl -fsSL http://fudankw.cn:9000/files/ga_install.sh)" +``` + +If the user explicitly wants a developer checkout: + +```bash +git clone https://github.com/lsdefine/GenericAgent.git +cd GenericAgent +uv venv +uv pip install -e ".[ui]" +cp mykey_template.py mykey.py +``` + +### Step 2: Configure keys safely + +- Read `mykey_template.py` before editing `mykey.py`. +- Configure only keys the user actually provides. +- Never print the full key back to the user. +- If the user is unsure, run `python assets/configure_mykey.py` or ask which provider they want to use. + +Suggested question: + +> Which LLM should GenericAgent use? GA supports OpenAI-compatible endpoints and Anthropic Claude native APIs. Which provider do you want to configure, and do you already have an API key? + +### Step 3: Verify setup + +Run: + +```bash +cd ~/GenericAgent +python -c "import agent_loop; print('OK')" +git rev-parse --short HEAD +``` + +Then verify one frontend: + +```bash +python launch.pyw +# or, for terminal-only environments: +python frontends/tuiapp_v2.py +``` + +### Step 4: Verification and Next Steps + +Read this document again and think through whether you have done everything correctly: + +- You inspected the OS, existing install, Python version, Git, and key readiness before acting. +- You used the correct GA install command for this English guide, including `GLOBAL=1` where shown. +- You configured only real user-provided keys and did not reveal secrets. +- You verified import, Git commit hash, and at least one frontend. + +#### Say 'Congratulations! 🎉' to the user + +Say to user: Congratulations! 🎉 You have successfully set up GenericAgent! Double-click `frontends/GenericAgent.exe` on Windows, or run `python frontends/tuiapp_v2.py` in your terminal to start using it. + +#### Report the concrete result + +Tell the user: + +1. Install path. +2. Git commit hash. +3. Which frontend you verified. +4. Whether `mykey.py` is configured, without revealing secrets. +5. Recommended next reading: [Hello GenericAgent](https://datawhalechina.github.io/hello-generic-agent/) and [GETTING_STARTED.md](GETTING_STARTED.md). + +#### Ask for a Star ⭐ + +After everything is set up and working, you may ask: + +> If you found this helpful, would you like to star the GenericAgent repository? It helps the project grow! ⭐ + +Only open or run anything related to starring the repository if the user explicitly agrees. + +### Never do these + +- Do **not** run `pip install genericagent`; GA is not a PyPI package. +- Do **not** delete `~/GenericAgent` or `%USERPROFILE%\GenericAgent` for a "clean install" without explicit user approval. +- Do **not** fabricate API keys or treat sample keys as real. +- Do **not** assume `ga` is a valid shell command; check first. +- Do **not** rely on `ga update`; use `git fetch`, `git pull`, and reinstall dependencies as shown above. + +--- + +## References + +- Main README: [README.md](../README.md) +- Getting started: [GETTING_STARTED.md](GETTING_STARTED.md) +- Datawhale tutorial: +- Technical report: diff --git a/docs/installation_zh.md b/docs/installation_zh.md new file mode 100644 index 0000000..c124cc7 --- /dev/null +++ b/docs/installation_zh.md @@ -0,0 +1,324 @@ +# 安装指南(中文) + +这是 **GenericAgent** 的详细安装指南。 + +两类读者: + +- **[面向用户(For Humans)](#面向用户-for-humans)** —— 你自己安装 GA。 +- **[面向 LLM Agent(For LLM Agents)](#面向-llm-agent-for-llm-agents)** —— 你是 Claude Code、Codex 等编程 Agent,需要替人类用户安装 GA。请先读这一段,避免靠猜。 + +> 最短安装命令见主 [README](../README.md#-快速开始)。这份文档补充平台差异、Key 配置、验证、排障,以及 Agent 自动安装时的安全规则。 + +--- + +## 面向用户(For Humans) + +### 准备工作 + +| 要求 | 说明 | +|---|---| +| **操作系统** | Windows 10/11、macOS 12+,或任意现代 Linux。 | +| **Python** | 推荐 **Python 3.11 或 3.12**。**不要使用 Python 3.14**,它与 `pywebview` 及部分 GA 依赖不兼容。方法一的一键脚本会准备隔离运行环境,通常不需要手动装 Python。 | +| **Git** | 推荐安装,方便升级和自我进化。 | +| **LLM API Key** | GA 原生支持两类协议:**OpenAI 兼容接口** 和 **Anthropic Claude 原生接口**。GPT 系列、Claude、Kimi、MiniMax、DeepSeek、GLM、Qwen、通过 OAI 兼容网关接入的 Gemini 等,都可以在 `mykey.py` 中配置。 | + +### 方法一:一键安装(推荐) + +这是最省心的路径。脚本会准备隔离环境、下载 GenericAgent、安装核心依赖,并得到一个可以直接运行的本地项目目录。 + +**Windows PowerShell** + +```powershell +powershell -ExecutionPolicy Bypass -c "irm http://fudankw.cn:9000/files/ga_install.ps1 | iex" +``` + +**Linux / macOS** + +```bash +curl -fsSL http://fudankw.cn:9000/files/ga_install.sh | bash +``` + +安装完成后,Windows 用户可双击: + +```text +frontends/GenericAgent.exe +``` + +也可以进入项目目录运行: + +```bash +python launch.pyw +``` + +> GenericAgent 更推荐由 Agent 在使用中自举环境,而不是预先手动装完整依赖。先把最小系统跑起来,需要什么工具再让 GA 自己安装。 + +#### 自定义安装路径 + +```bash +INSTALL_DIR="$HOME/work/GenericAgent" bash -c "$(curl -fsSL http://fudankw.cn:9000/files/ga_install.sh)" +``` + +```powershell +$env:INSTALL_DIR="C:\dev\GenericAgent"; powershell -ExecutionPolicy Bypass -c "irm http://fudankw.cn:9000/files/ga_install.ps1 | iex" +``` + +#### 强制重新安装 + +仅在明确想刷新已安装文件时使用。请先备份 `mykey.py`、`memory/`、`skills/` 和本地工作成果。 + +```bash +FORCE=1 bash -c "$(curl -fsSL http://fudankw.cn:9000/files/ga_install.sh)" +``` + +### 方法二:Python 安装(开发者) + +适合想要可编辑源码目录的开发者。 + +```bash +git clone https://github.com/lsdefine/GenericAgent.git +cd GenericAgent +uv venv +uv pip install -e ".[ui]" # 核心 + UI 依赖 +cp mykey_template.py mykey.py # 填入你的 LLM API Key +python launch.pyw +``` + +完整引导流程见 [GETTING_STARTED.md](GETTING_STARTED.md)。 + +### 配置 LLM Key + +1. 打开已安装的 `GenericAgent` 目录。 +2. 如果没有 `mykey.py`,从 `mykey_template.py` 复制一份。 +3. 填入一个真实可用的模型服务商配置。**不要**把示例 Key 当真。 +4. 不确定字段含义时,先读 `mykey_template.py` 里的注释。 + +GA 支持: + +- **OpenAI 兼容接口** —— Chat Completions / Responses 形态的接口。 +- **Anthropic Claude 原生接口** —— Claude Messages API。 + +可选配置向导: + +```bash +python assets/configure_mykey.py +``` + +### 前端启动方式 + +#### 桌面端 + +一键安装自带桌面端,双击: + +```text +frontends/GenericAgent.exe +``` + +#### 终端 UI + +基于 [Textual](https://github.com/Textualize/textual) 的轻量键盘驱动界面。支持多会话并发、实时流式输出,有终端就能跑。 + +```bash +python frontends/tuiapp_v2.py +``` + +#### Streamlit UI + +```bash +python launch.pyw +``` + +### 验证安装 + +在 GenericAgent 目录下运行: + +```bash +python -c "import agent_loop; print('OK')" +git rev-parse --short HEAD +``` + +然后至少启动一个前端: + +```bash +python launch.pyw +# 或 +python frontends/tuiapp_v2.py +``` + +### 常见坑 + +#### 不支持 Python 3.14 + +如果系统 `python --version` 显示 3.14,不要用它跑 GA。请走一键安装,或用 `uv` 创建 Python 3.11 / 3.12 环境。 + +#### `ga` 命令冲突 + +有些系统已经把 `ga` 分配给其他工具。先检查: + +```bash +type ga +``` + +如果解析到意料之外的位置,就不要依赖这个快捷命令。请进入安装目录运行 `python launch.pyw` 或 `python frontends/tuiapp_v2.py`。 + +#### Windows 上 TUI 显示异常 + +TUI 在 Windows 上依赖终端、字体和 `textual` 版本。 + +1. 先升级 Textual:`pip install -U textual`。 +2. PowerShell / cmd 对 Unicode 和键位支持较弱,**Windows 上推荐用 Git Bash**。 +3. 仍然异常时,可以让 GA 自己修一遍。参考 Prompt: + > *"我在 Windows 的 PowerShell / cmd / Git Bash 中使用 `frontends/tuiapp_v2.py` 体验非常差,出现了一堆不兼容问题。请参考 Claude Code 在 Windows 终端的最佳配置,把所有字体和显示不兼容的问题修一遍。"* + +#### Linux 上 `pywebview` 安装失败 + +桌面模式依赖 GTK / WebKit。Debian / Ubuntu: + +```bash +sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev +pip install pywebview +``` + +无头服务器建议使用 TUI,或用浏览器方式运行 Streamlit,不要强行启桌面模式。 + +### 升级已有安装 + +不要为了升级而删除一个可用的 GA 目录。里面可能有用户的 `mykey.py`、记忆、技能和本地成果。 + +```bash +cd ~/GenericAgent +git fetch +git pull +uv pip install -e ".[ui]" +``` + +如果 `git pull` 报冲突: + +- `mykey.py`、`memory/`、`skills/`、`temp/` 和用户成果:本地优先。 +- `agent*.py`、`frontends/*`、`reflect/*`、`tools/*` 等核心代码:通常 upstream 优先,除非用户另有说明。 +- 明确解决冲突后,再 `git add` 并提交合并。 + +--- + +## 面向 LLM Agent(For LLM Agents) + +> **重要:用 `curl` 获取本文,不要依赖会摘要网页的读取器。** 摘要常会丢掉环境变量、命令引号和安全规则。 +> +> ```bash +> curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/main/docs/installation.md +> ``` + +你正在替人类用户安装 **GenericAgent**。请直接执行、逐步验证;除非用户明确授权,不要做破坏性清理。 + +### 第 0 步:先探测再行动 + +检查: + +1. 操作系统:`uname -a`、`$env:OS`,或 `python -c "import platform; print(platform.system())"`。 +2. 是否已安装:`~/GenericAgent/launch.pyw`、`%USERPROFILE%\GenericAgent\launch.pyw`,或 `.git` 目录。 +3. Python 版本:开发者路径必须使用 3.11 或 3.12。 +4. Git 是否可用:`git --version`。 +5. API Key 是否准备好:没有就问用户,绝不要伪造。 + +### 第 1 步:选择安装路径 + +除非用户已经有源码目录,否则默认走一键安装。 + +**Windows PowerShell** + +```powershell +powershell -ExecutionPolicy Bypass -c "irm http://fudankw.cn:9000/files/ga_install.ps1 | iex" +``` + +**Linux / macOS** + +```bash +curl -fsSL http://fudankw.cn:9000/files/ga_install.sh | bash +``` + +如果用户明确要开发者源码安装: + +```bash +git clone https://github.com/lsdefine/GenericAgent.git +cd GenericAgent +uv venv +uv pip install -e ".[ui]" +cp mykey_template.py mykey.py +``` + +### 第 2 步:安全配置 Key + +- 编辑 `mykey.py` 前先读 `mykey_template.py`。 +- 只配置用户真实提供的 Key。 +- 不要把完整 Key 打印回给用户。 +- 用户不确定时,运行 `python assets/configure_mykey.py`,或询问要使用哪家模型服务商。 + +建议提问: + +> 你希望 GenericAgent 使用哪个 LLM?GA 支持 OpenAI 兼容接口和 Anthropic Claude 原生接口。你想配置哪家服务商,API Key 准备好了吗? + +### 第 3 步:验证安装 + +运行: + +```bash +cd ~/GenericAgent +python -c "import agent_loop; print('OK')" +git rev-parse --short HEAD +``` + +然后验证至少一个前端: + +```bash +python launch.pyw +# 或,无桌面环境时: +python frontends/tuiapp_v2.py +``` + +### 第 4 步:验证与下一步 + +重新读一遍本文,并思考你是否已经正确完成所有步骤: + +- 行动前检查了操作系统、已有安装、Python 版本、Git 和 Key 准备情况。 +- 使用了本文中文默认安装命令;不要擅自混入英文版的 `GLOBAL=1` 默认脚本。 +- 只配置了用户真实提供的 Key,且没有泄露密钥。 +- 已验证 import、Git commit hash,并至少验证了一个前端。 + +#### 对用户说 “Congratulations! 🎉” + +对用户说:Congratulations! 🎉 你已经成功安装 GenericAgent!Windows 下可双击 `frontends/GenericAgent.exe`,或在终端运行 `python frontends/tuiapp_v2.py` 开始使用。 + +#### 回报具体结果 + +告诉用户: + +1. 安装路径。 +2. Git commit hash。 +3. 已验证哪个前端。 +4. `mykey.py` 是否已配置,但不要泄露密钥。 +5. 下一步阅读:[Hello GenericAgent](https://datawhalechina.github.io/hello-generic-agent/) 和 [GETTING_STARTED.md](GETTING_STARTED.md)。 + +#### 请求 Star ⭐ + +确认安装可用后,可以询问: + +> 如果你觉得 GenericAgent 有帮助,愿意给仓库点一个 Star 吗?这会帮助项目成长!⭐ + +只有在用户明确同意后,才可以打开或执行任何与 Star 仓库相关的操作。 + +### 绝对不要做 + +- 不要运行 `pip install genericagent`;GA 不是 PyPI 包。 +- 未经明确授权,不要删除 `~/GenericAgent` 或 `%USERPROFILE%\GenericAgent` 做“干净安装”。 +- 不要伪造 API Key,也不要把示例 Key 当真。 +- 不要假设 `ga` 命令一定可用;先检查。 +- 不要依赖 `ga update`;按上面的 `git fetch`、`git pull` 和重装依赖流程做。 + +--- + +## 参考资料 + +- 主 README:[README.md](../README.md) +- Getting Started:[GETTING_STARTED.md](GETTING_STARTED.md) +- Datawhale 教程: +- 技术报告: +- English installation guide: [installation.md](installation.md) diff --git a/docs/macos_desktop_installation_zh.md b/docs/macos_desktop_installation_zh.md new file mode 100644 index 0000000..71b13fc --- /dev/null +++ b/docs/macos_desktop_installation_zh.md @@ -0,0 +1,85 @@ +# GenericAgent 桌面版安装指南 + +## 📦 安装步骤 + +### 第一步:打开安装包 + +双击下载的 `GenericAgent_x.x.x_aarch64.dmg` 文件,会弹出一个安装窗口。 + +将左边的 **GenericAgent** 图标拖到右边的 **Applications** 文件夹图标上,等待拷贝完成。 + +拷贝完成后,可以右键点击桌面上的 DMG 图标,选择「推出」来关闭安装包。 + +--- + +### 第二步:首次打开前的准备(重要) + +由于本应用暂未通过 Apple 官方签名认证,macOS 会阻止首次打开。这是正常的安全提示,不代表应用有问题。 + +请按以下步骤解除限制: + +#### 1. 打开「终端」应用 + +不知道终端是什么?别担心,它就是一个可以输入命令的工具。打开方式: + +- 按下键盘上的 `Command(⌘) + 空格键`,会弹出搜索框(Spotlight) +- 输入 `终端` 或 `Terminal`,按回车键打开 + +你会看到一个黑色或白色的文字窗口,里面有一个闪烁的光标,这就是终端。 + +#### 2. 输入解除限制的命令 + +在终端窗口中,复制粘贴以下这行命令(整行复制,一个字都不要漏��: + +``` +xattr -cr /Applications/GenericAgent.app +``` + +粘贴方法:在终端窗口里按 `Command(⌘) + V` + +然后按 `回车键(Enter)` 执行。 + +> 如果终端要求输入密码,输入你的 Mac 开机密码(输入时不会显示任何字符,这是正常的),然后按回车。 + +执行完毕后,终端不会有任何提示,这代表成功了。 + +#### 3. 打开 GenericAgent + +现在可以正常打开应用了: + +- 打开 Finder → 侧边栏点击「应用程序」 +- 找到 **GenericAgent**,双击打开 + +首次打开可能还会弹出一个确认框,点击「打开」即可。之后就不会再弹出了。 + +--- + +## ❓ 常见问题 + +### Q: 提示「GenericAgent 已损坏,无法打开」怎么办? + +这不是真的损坏,是 macOS 的安全机制。请回到第二步,确保在终端中执行了 `xattr -cr` 命令。 + +### Q: 提示「无法打开,因为无法验证开发者」怎么办? + +方法一(推荐):执行第二步的终端命令。 + +方法二:右键点击 GenericAgent.app → 选择「打开」→ 在弹出的对话框中点击「打开」。 + +### Q: 我的 Mac 是 Intel 芯片的,能用吗? + +当前版本仅支持 Apple Silicon(M1/M2/M3/M4)芯片的 Mac。如果你的 Mac 是 2020 年之前购买的,大概率是 Intel 芯片,暂时无法使用本安装包。 + +查看方法:点击左上角 → 「关于本机」,如果芯片一栏显示 Apple M1/M2/M3/M4,就可以使用。 + +### Q: 终端命令执行后没有任何反应? + +没有反应就是成功了。Unix/macOS 的设计哲学是「没有消息就是好消息」。 + +--- + +## 🔧 系统要求 + +- macOS 12 (Monterey) 或更高版本 +- Apple Silicon 芯片(M1/M2/M3/M4) +- 约 50MB 可用磁盘空间 diff --git a/frontends/DESKTOP_PET_README.md b/frontends/DESKTOP_PET_README.md new file mode 100644 index 0000000..1ee8798 --- /dev/null +++ b/frontends/DESKTOP_PET_README.md @@ -0,0 +1,175 @@ +# Desktop Pet Skin System + +## 快速开始 + +运行桌面宠物: +```bash +python3 desktop_pet_v2.pyw +``` + +## 功能特性 + +### 1. 多皮肤支持 +- 自动发现 `skins/` 目录下的所有皮肤 +- 右键菜单切换皮肤 +- 支持 sprite sheet 和 GIF 两种格式 + +### 2. 多动画状态 +- **idle** - 待机动画 +- **walk** - 行走动画 +- **run** - 跑步动画 +- **sprint** - 冲刺动画 + +右键菜单可切换动画状态 + +### 3. 交互功能 +- **单击** - 拖动宠物 +- **双击** - 关闭程序 +- **右键** - 打开菜单(切换皮肤/动画) + +### 4. HTTP 远程控制 +```bash +# 显示消息 +curl "http://127.0.0.1:51983/?msg=Hello" + +# 切换动画状态 +curl "http://127.0.0.1:51983/?state=run" + +# POST 消息 +curl -X POST -d "任务完成" http://127.0.0.1:51983/ +``` + +## 添加新皮肤 + +### 目录结构 +``` +skins/ +└── your-skin-name/ + ├── skin.json # 配置文件(必需) + ├── idle.png # 动画资源 + ├── walk.png + ├── run.png + └── sprint.png +``` + +### skin.json 配置示例 + +#### Sprite Sheet 格式(推荐) +```json +{ + "name": "My Pet", + "version": "1.0.0", + "author": "Your Name", + "description": "描述", + "format": "sprite", + "animations": { + "idle": { + "file": "idle.png", + "loop": true, + "sprite": { + "frameWidth": 44, + "frameHeight": 31, + "frameCount": 6, + "columns": 6, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "walk.png", + "loop": true, + "sprite": { + "frameWidth": 65, + "frameHeight": 32, + "frameCount": 8, + "columns": 8, + "fps": 8, + "startFrame": 0 + } + } + } +} +``` + +#### GIF 格式 +```json +{ + "name": "My Pet", + "format": "gif", + "animations": { + "idle": { + "file": "idle.gif", + "loop": true + }, + "walk": { + "file": "walk.gif", + "loop": true + } + } +} +``` + +### 配置说明 + +- **frameWidth/frameHeight**: 单帧尺寸(像素) +- **frameCount**: 帧数 +- **columns**: sprite sheet 的列数 +- **fps**: 播放帧率 +- **startFrame**: 起始帧索引(从 0 开始) + +### Sprite Sheet 布局 + +``` ++-------+-------+-------+-------+ +| 帧0 | 帧1 | 帧2 | 帧3 | ← 第一行 ++-------+-------+-------+-------+ +| 帧4 | 帧5 | 帧6 | 帧7 | ← 第二行 ++-------+-------+-------+-------+ +``` + +如果 `columns=4, startFrame=2, frameCount=3`,则读取:帧2, 帧3, 帧4 + +## 已包含的皮肤 + +1. **Glube** - 像素风小怪兽(多文件 sprite) +2. **Vita** - 像素风小恐龙(单文件 sprite) +3. **Doux** - 像素风小恐龙(单文件 sprite) + +## 从 ai-bubu 导入更多皮肤 + +ai-bubu 项目包含更多皮肤资源,可以直接复制: + +```bash +# 复制皮肤 +cp -r ai-bubu-main/packages/app/public/skins/boy frontends/skins/ +cp -r ai-bubu-main/packages/app/public/skins/dinosaur frontends/skins/ +cp -r ai-bubu-main/packages/app/public/skins/line frontends/skins/ +cp -r ai-bubu-main/packages/app/public/skins/mort frontends/skins/ +cp -r ai-bubu-main/packages/app/public/skins/tard frontends/skins/ +``` + +## 与 stapp.py 集成 + +在 `stapp.py` 中点击"🐱 桌面宠物"按钮会自动启动桌面宠物,并在每个 turn 结束时发送通知。 + +## 故障排查 + +### 皮肤不显示 +1. 检查 `skin.json` 格式是否正确 +2. 确认图片文件存在 +3. 检查 sprite 配置参数是否匹配图片尺寸 + +### 动画不流畅 +- 调整 `fps` 参数 +- 检查帧数是否正确 + +### 透明背景问题 +- 确保 PNG 文件包含 alpha 通道 +- 使用 RGBA 模式的图片 + +## 技术细节 + +- 基于 Tkinter + PIL/Pillow +- 支持透明背景(#01FF01 色键) +- 窗口置顶、无边框 +- HTTP 服务器端口:51983 diff --git a/frontends/at_complete.py b/frontends/at_complete.py new file mode 100644 index 0000000..3499652 --- /dev/null +++ b/frontends/at_complete.py @@ -0,0 +1,272 @@ +"""@ file completion — shared UI-less logic for tui_v2 / tui_v3. + +File index (os.scandir, cached per root) + fuzzy match + @token detection + +insert text. No UI deps; each front-end renders candidates its own way and +calls candidates_for(query, root). Index root is the front-end's choice +(session workspace, else CWD). Submit-time: completion-only does NOT read +content, but absolutize_mentions() rewrites @relative → @absolute so the +agent's file_read (relative to its own cwd) can locate the file. The +content-injecting auto-read variant lives in +temp/plan_v2_at_mention/autoread_version.py. +""" + +import os +import re +import threading + +# ---------------------------------------------------------------- index + +_IGNORE_DIRS = { + ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", + ".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build", + ".next", ".idea", ".vscode", "target", ".cache", ".eggs", + "model_responses", # GA 会话日志(上千个 .txt),未绑时根=temp 会淹没 @ 候选 +} +_IGNORE_EXT = {".pyc", ".pyo", ".so", ".o", ".class", ".lock", ".dll", ".exe"} +_MAX_FILES = 50_000 # 超大目录宁缺毋卡:到上限就停 + + +def scan_files(root: str, max_files: int = _MAX_FILES) -> list[str]: + """Collect relative file paths under root, '/'-normalized. + + os.scandir over os.walk: one syscall yields is_dir without an extra + stat per entry. Dotted dirs are skipped wholesale (.git, .venv...). + """ + out: list[str] = [] + stack = [root] + while stack and len(out) < max_files: + d = stack.pop() + try: + with os.scandir(d) as it: + for e in it: + try: + if e.is_dir(follow_symlinks=False): + if e.name not in _IGNORE_DIRS and not e.name.startswith("."): + stack.append(e.path) + elif e.is_file(follow_symlinks=False): + if os.path.splitext(e.name)[1].lower() not in _IGNORE_EXT: + rel = os.path.relpath(e.path, root).replace("\\", "/") + out.append(rel) + if len(out) >= max_files: + return out + except OSError: + continue + except OSError: + continue + return out + + +class FileIndexCache: + """Per-root background file index. warm() is idempotent-cheap: a + rebuild is only started when none is in flight.""" + + def __init__(self, root: str): + self.root = root + self._files: list[str] = [] + self._lock = threading.Lock() + self._building = False + self.ready = threading.Event() + + def warm(self) -> None: + with self._lock: + if self._building: + return + self._building = True + + def _build(): + try: + files = scan_files(self.root) + with self._lock: + self._files = files + self.ready.set() + finally: + with self._lock: + self._building = False + + threading.Thread(target=_build, name="ga-at-index", daemon=True).start() + + def snapshot(self) -> list[str]: + with self._lock: + return self._files + + +_indexes: dict[str, FileIndexCache] = {} +_indexes_lk = threading.Lock() + + +def get_index(root: str) -> FileIndexCache: + key = os.path.normcase(os.path.realpath(root or os.getcwd())) + with _indexes_lk: + idx = _indexes.get(key) + if idx is None: + idx = _indexes[key] = FileIndexCache(root) + return idx + + +# ---------------------------------------------------------------- fuzzy + +def _subseq_score(q: str, path: str): + """Subsequence match score (higher = better), None when q doesn't + fully appear in order. Contiguous runs dominate (fzf-style): scattered + one-char hits across a long path must not beat a tight cluster. + Word-boundary hits and basename substring add on top; ties broken by + caller on shorter path.""" + if not q: + return 0 + score, qi, prev_hit = 0, 0, -2 + for pi, ch in enumerate(path): + if qi < len(q) and ch == q[qi]: + score += 1 + if pi == prev_hit + 1: + score += 2 # contiguous run: the dominant signal + if pi == 0 or path[pi - 1] in "/\\_-. ": + score += 3 + prev_hit = pi + qi += 1 + if qi < len(q): + return None + base = path.rsplit("/", 1)[-1] + if q in base: + score += 8 + elif q in path: + score += 4 + return score + + +def fuzzy_rank(query: str, files: list[str], limit: int = 10) -> list[str]: + q = query.lower() + if not q: + # bare `@`: surface shallow paths first for discoverability + return sorted(files, key=lambda f: (f.count("/"), f))[:limit] + scored = [] + for f in files: + s = _subseq_score(q, f.lower()) + if s is not None: + scored.append((s, f)) + scored.sort(key=lambda x: (-x[0], len(x[1]), x[1])) + return [f for _, f in scored[:limit]] + + +# ------------------------------------------------------- edit-time token + +# `(?:^|\s)@` 前置:@ 前必须是行首或空白 → 邮箱/代码里的 a@b 不触发。 +# 字符集含路径分隔符与 ~ :,\w 在 unicode 下覆盖中文文件名。 +_AT_TOKEN_RE = re.compile(r"(?:^|\s)(@[\w\-./\\~:]*)$", re.UNICODE) + + +def find_at_token(line_before_cursor: str): + """Return (query, at_pos) when the cursor sits in an @token being + typed on this line, else None. at_pos is the index of '@'.""" + m = _AT_TOKEN_RE.search(line_before_cursor) + if not m: + return None + tok = m.group(1) + return tok[1:], m.start(1) + + +def format_pick(path: str) -> str: + """`@path` insert text; dirs get no trailing space (keep completing next + level), files get one (close token). Spaces → quoted.""" + trailing = '' if path.endswith(('/', '\\')) else ' ' + return f'@"{path}"{trailing}' if ' ' in path else f'@{path}{trailing}' + + +# --- path-like completion: an explicit-path @token (~/ / ./ ../ or C:\) goes +# to live directory completion instead of index fuzzy — this is how absolute +# paths outside the index root get completed level by level (claude-code parity). + +def is_path_like(token: str) -> bool: + if token in ('~', '.', '..'): + return True + if token.startswith(('~/', '~\\', './', '.\\', '../', '..\\', '/', '\\')): + return True + return len(token) >= 3 and token[0].isalpha() and token[1] == ':' and token[2] in '/\\' + + +def path_completions(token: str, root: str, limit: int = 15) -> list[str]: + """readdir the real dir of a path-like token, prefix-match, dirs first. + `~` expanded, relative → root, absolute as-is; candidates keep the token's + spelling, dirs carry a trailing '/'.""" + sep = max(token.rfind('/'), token.rfind('\\')) + if sep >= 0: + dir_part, prefix = token[:sep + 1], token[sep + 1:] + elif token in ('~', '.', '..'): + dir_part, prefix = token.rstrip('/\\') + '/', '' + else: + return [] + exp = os.path.expanduser(dir_part) + real_dir = exp if os.path.isabs(exp) else os.path.join(root, exp) + try: + with os.scandir(real_dir) as it: + entries = list(it) + except OSError: + return [] + pl = prefix.lower() + rows = [] + for e in entries: + nm = e.name + if pl and not nm.lower().startswith(pl): + continue + if nm.startswith('.') and not prefix.startswith('.'): # 隐藏项需显式 . 才出 + continue + try: + is_dir = e.is_dir() + except OSError: + is_dir = False + rows.append((not is_dir, nm.lower(), dir_part + nm + ('/' if is_dir else ''))) + rows.sort(key=lambda r: (r[0], r[1])) # 目录优先 + 字母序 + return [d for _, _, d in rows[:limit]] + + +def candidates_for(query: str, root: str, limit: int = 15, absolute: bool = False) -> list[str]: + """@token candidates: path-like → directory completion, else index fuzzy. + Single dispatch point shared by both front-ends. `absolute=True` returns + fuzzy hits as absolute paths (front-end shows full path when no workspace + is bound, since the relative root isn't obvious to the user).""" + if is_path_like(query): + return path_completions(query, root, limit) + idx = get_index(root) + files = idx.snapshot() + if not files: + idx.warm() # 惰性兜底:该根还没建索引 → 后台建(本次可能空,下次有) + res = fuzzy_rank(query, files, limit) if files else [] + if absolute: + res = [os.path.normpath(os.path.join(root, c)) for c in res] + return res + + +# ------------------------------------------------------ submit-time absolutize +# A fuzzy candidate inserts a path relative to the @ root (workspace/CWD), but +# the agent's file_read resolves relative to its own ./temp cwd — so a bare +# `@frontends/x.py` won't be found. At submit we rewrite each @mention naming a +# real file to an absolute path; display keeps the short form. Still no content +# read — this only completes the path so the agent can locate it. + +_AT_ABS_RE = re.compile(r'(^|\s)@("([^"]+)"|([\w\-./\\~:#]+))', re.UNICODE) +_LINE_SUFFIX_RE = re.compile(r'(#L\d+(?:-\d+)?)$') + + +def absolutize_mentions(text: str, root: str) -> str: + """@relative → @absolute (root-resolved, ~ expanded, quoted if it gains a + space), `#Lx-y` suffix kept. Only existing paths are rewritten; decorative + @words / typos pass through unchanged.""" + def repl(m): + lead, quoted, bare = m.group(1), m.group(3), m.group(4) + raw = quoted if quoted is not None else bare + trail = '' + if quoted is None: # strip trailing prose punctuation + stripped = raw.rstrip(',。,;;))]》>') + trail, raw = raw[len(stripped):], stripped + sm = _LINE_SUFFIX_RE.search(raw) + suffix = sm.group(1) if sm else '' + path = raw[:len(raw) - len(suffix)] if suffix else raw + if not path: + return m.group(0) + exp = os.path.expanduser(path) + absp = os.path.normpath(exp if os.path.isabs(exp) else os.path.join(root, exp)) + if not os.path.exists(absp): # decorative / typo → leave as-is + return m.group(0) + full = absp + suffix + token = f'@"{full}"' if ' ' in full else f'@{full}' + return lead + token + trail + return _AT_ABS_RE.sub(repl, text) diff --git a/frontends/btw_cmd.py b/frontends/btw_cmd.py new file mode 100644 index 0000000..fca03b4 --- /dev/null +++ b/frontends/btw_cmd.py @@ -0,0 +1,142 @@ +"""`/btw` 命令:side question — 不打断主 Agent 的临时 subagent 问答。 + +- 持锁 deepcopy backend.history → 后台线程 backend.raw_ask 单次拉答 +- 主 agent backend.history 零写入;不入 task_queue +- 答案 → display_queue 'done'(install 路径)或同步 return(frontend 路径) + +复用 backend.raw_ask + make_messages,不新建 LLM 实例。 +""" +from __future__ import annotations +import copy, os, threading, time +from typing import Optional + + +_WRAPPER_ZH = """ +这是用户的临时插问 (side question)。主 agent 仍在后台运行,**不会被打断**。 + +身份与边界: +- 你是一个独立的轻量 sub-agent +- 上下文里能看到主 agent 与用户的完整对话、最近的工具调用与结果 +- 用户在问当前进展或顺便确认某事——基于已有信息**一次性**作答 +- 没有任何工具可用:不要"让我查一下" / "我去试试" / 任何承诺动作 +- 信息不足就坦白说"基于目前对话我不知道" + +侧问内容如下: + + +{question}""" + +_WRAPPER_EN = """ +This is a side question from the user. The main agent is NOT interrupted — it continues in the background. + +Identity & boundaries: +- You are an independent lightweight sub-agent +- You can see the full conversation between the main agent and the user, plus recent tool calls/results +- The user is asking about current progress or a quick aside — answer in **one shot** from existing info +- You have NO tools — never say "let me check" / "I'll try" / any action promise +- If info is missing, just say "based on the conversation I don't know" + +Question: + + +{question}""" + +_TIMEOUT_SEC = 120 + + +def _wrapper(): return _WRAPPER_EN if os.environ.get('GA_LANG') == 'en' else _WRAPPER_ZH + + +def _strip_cmd(query): + s = (query or '').strip() + return s[len('/btw'):].strip() if s.startswith('/btw') else s + + +def _help_text(): + return ('**/btw 用法**:side question — 临时问主 agent 当前进展,不打断主线\n\n' + '`/btw <你的问题>`\n\n' + '行为:抓取当前对话上下文 → 单轮纯文本作答(无工具)→ 主 agent 历史不变。') + + +def _snapshot_history(backend): + """Lock + deepcopy: defends against concurrent compress_history_tags mutating inner blocks.""" + with backend.lock: + return copy.deepcopy(list(backend.history)) + + +def _build_wire(backend, history, sidequest_msg): + """history + sidequest → wire-format. Dispatches: BaseSession subclasses → make_messages, + Native* → raw pairs (raw_ask runs _fix/_drop/_ensure transforms itself).""" + msgs = history + [sidequest_msg] + if hasattr(backend, 'make_messages'): + return backend.make_messages(msgs) + return [{"role": m["role"], "content": list(m.get("content", []))} for m in msgs] + + +def _ask(agent, question, deadline): + """One-shot raw_ask against current backend; never mutates backend.history.""" + backend = agent.llmclient.backend + user_msg = {"role": "user", + "content": [{"type": "text", "text": _wrapper().format(question=question)}]} + wire = _build_wire(backend, _snapshot_history(backend), user_msg) + text = '' + for chunk in backend.raw_ask(wire): + text += chunk + if time.time() > deadline: + return text + '\n\n⚠️ /btw 超时,仅返回部分回复。' + return text + + +def _format(question, body, took): + head = f'> 🟡 /btw {question}\n\n' + return head + (body.strip() or '*(空回复)*') + f'\n\n*({took:.1f}s)*' + + +def _run(agent, question, deadline): + """Catches errors at the boundary so neither caller path needs its own try/except.""" + try: return _ask(agent, question, deadline) + except Exception as e: return f'❌ /btw 失败: {type(e).__name__}: {e}' + + +def handle(agent, query, display_queue) -> Optional[str]: + """Slash-cmd entry (server-side, install path). Spawn worker; return None to consume.""" + question = _strip_cmd(query) + if not question or question in ('help', '?', '-h', '--help'): + display_queue.put({'done': _help_text(), 'source': 'system'}) + return None + started = time.time() + deadline = started + _TIMEOUT_SEC + + def worker(): + body = _run(agent, question, deadline) + display_queue.put({'done': _format(question, body, time.time() - started), 'source': 'system'}) + + threading.Thread(target=worker, daemon=True, name='btw-sidequest').start() + return None + + +def handle_frontend_command(agent, query) -> str: + """Sync entry for frontends wanting a string back (tg/wx/stapp/...).""" + question = _strip_cmd(query) + if not question or question in ('help', '?', '-h', '--help'): + return _help_text() + started = time.time() + body = _run(agent, question, started + _TIMEOUT_SEC) + return _format(question, body, time.time() - started) + + +def install(cls): + """Idempotent monkey-patch: intercept /btw before original dispatch.""" + orig = cls._handle_slash_cmd + if getattr(orig, '_btw_patched', False): return + + def patched(self, raw_query, display_queue): + s = (raw_query or '').strip() + if s == '/btw' or s.startswith('/btw ') or s.startswith('/btw\t'): + r = handle(self, raw_query, display_queue) + if r is None: return None + return r + return orig(self, raw_query, display_queue) + + patched._btw_patched = True + cls._handle_slash_cmd = patched diff --git a/frontends/chat_bubble.png b/frontends/chat_bubble.png new file mode 100644 index 0000000..c1969df Binary files /dev/null and b/frontends/chat_bubble.png differ diff --git a/frontends/chatapp_common.py b/frontends/chatapp_common.py new file mode 100644 index 0000000..befaf1c --- /dev/null +++ b/frontends/chatapp_common.py @@ -0,0 +1,352 @@ +import ast, asyncio, glob, json, os, queue as Q, re, socket, sys, time + +# 确保能导入上级目录的模块(如 agentmain) +_parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + +HELP_COMMANDS = ( + ("/help", "显示帮助"), + ("/status", "查看状态"), + ("/stop", "停止当前任务"), + ("/new", "开启新对话并清空当前上下文"), + ("/restore", "恢复上次对话历史"), + ("/continue", "列出可恢复会话"), + ("/continue [n]", "恢复第 n 个会话"), + ("/btw ", "side question — 临时插问主 agent 进展,不打断主线"), + ("/review [scope]", "in-session code review; 默认审当前 git diff"), + ("/llm", "查看当前模型列表"), + ("/llm [n]", "切换到第 n 个模型"), +) +TELEGRAM_MENU_COMMANDS = ( + ("help", "显示帮助"), + ("status", "查看状态"), + ("stop", "停止当前任务"), + ("new", "开启新对话并清空当前上下文"), + ("restore", "恢复上次对话历史"), + ("continue", "列出可恢复会话;/continue n 恢复第 n 个"), + ("btw", "临时插问主 agent 进展,不打断主线"), + ("review", "in-session code review;/review scope 指定范围"), + ("llm", "查看模型列表;/llm n 切换到指定模型"), +) + + +def build_help_text(commands=HELP_COMMANDS): + return "📖 命令列表:\n" + "\n".join(f"{cmd} - {desc}" for cmd, desc in commands) + + +HELP_TEXT = build_help_text() +FILE_HINT = "If you need to show files to user, use [FILE:filepath] in your response." +TAG_PATS = [r"<" + t + r">.*?" for t in ("thinking", "summary", "tool_use", "file_content")] +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RESTORE_GLOBS = ( + os.path.join(PROJECT_ROOT, "temp", "model_responses", "model_responses_*.txt"), + os.path.join(PROJECT_ROOT, "temp", "model_responses_*.txt"), +) +RESTORE_BLOCK_RE = re.compile( + r"^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)", + re.DOTALL | re.MULTILINE, +) +HISTORY_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL) +SUMMARY_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL) + + +def clean_reply(text): + for pat in TAG_PATS: + text = re.sub(pat, "", text or "", flags=re.DOTALL) + return re.sub(r"\n{3,}", "\n\n", text).strip() or "..." + + +def extract_files(text): + return re.findall(r"\[FILE:([^\]]+)\]", text or "") + + +def strip_files(text): + return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip() + + +def split_text(text, limit): + text, parts = (text or "").strip() or "...", [] + while len(text) > limit: + cut = text.rfind("\n", 0, limit) + if cut < limit * 0.6: + cut = limit + parts.append(text[:cut].rstrip()) + text = text[cut:].lstrip() + return parts + ([text] if text else []) or ["..."] + + +def _restore_log_files(): + files = [] + for pattern in RESTORE_GLOBS: + files.extend(glob.glob(pattern)) + return sorted(set(files)) + + +def _restore_text_pairs(content): + users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL) + resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL) + restored = [] + for u, r in zip(users, resps): + u, r = u.strip(), r.strip()[:500] + if u and r: + restored.extend([f"[USER]: {u}", f"[Agent] {r}"]) + return restored + + +def _native_prompt_obj(prompt_body): + try: + prompt = json.loads(prompt_body) + except Exception: + return None + if not isinstance(prompt, dict) or prompt.get("role") != "user": + return None + if not isinstance(prompt.get("content"), list): + return None + return prompt + + +def _native_prompt_text(prompt): + texts = [] + for block in prompt.get("content", []): + if isinstance(block, dict) and block.get("type") == "text": + text = block.get("text", "") + if isinstance(text, str) and text.strip(): + texts.append(text) + return "\n".join(texts).strip() + + +def _native_history_lines(prompt_text): + match = HISTORY_RE.search(prompt_text or "") + if not match: + return [] + restored = [] + for line in match.group(1).splitlines(): + line = line.strip() + if line.startswith("[USER]: ") or line.startswith("[Agent] "): + restored.append(line) + return restored + + +def _native_first_user_line(prompt_text): + text = (prompt_text or "").strip() + if not text or "" in text or text.startswith("### [WORKING MEMORY]"): + return "" + if text.startswith(FILE_HINT): + text = text[len(FILE_HINT):].lstrip() + if "### 用户当前消息" in text: + text = text.split("### 用户当前消息", 1)[-1].strip() + return text + + +def _native_response_summary(response_body): + try: + blocks = ast.literal_eval((response_body or "").strip()) + except Exception: + return "" + if not isinstance(blocks, list): + return "" + text_parts = [] + for block in blocks: + if isinstance(block, dict) and block.get("type") == "text": + text = block.get("text", "") + if isinstance(text, str) and text: + text_parts.append(text) + match = SUMMARY_RE.search("\n".join(text_parts)) + return (match.group(1).strip() if match else "")[:500] + + +def _restore_native_history(content): + blocks = RESTORE_BLOCK_RE.findall(content or "") + if not blocks: + return [] + pairs = [] + pending_prompt = None + for label, body in blocks: + if label == "Prompt": + pending_prompt = body + elif pending_prompt is not None: + pairs.append((pending_prompt, body)) + pending_prompt = None + for prompt_body, response_body in reversed(pairs): + prompt = _native_prompt_obj(prompt_body) + if prompt is None: + continue + prompt_text = _native_prompt_text(prompt) + restored = list(_native_history_lines(prompt_text)) + if restored: + summary = _native_response_summary(response_body) + summary_line = f"[Agent] {summary}" if summary else "" + if summary_line and (not restored or restored[-1] != summary_line): + restored.append(summary_line) + return restored + user_text = _native_first_user_line(prompt_text) + summary = _native_response_summary(response_body) + if user_text and summary: + return [f"[USER]: {user_text}", f"[Agent] {summary}"] + return [] + + +def format_restore(): + files = _restore_log_files() + if not files: + return None, "❌ 没有找到历史记录" + latest = max(files, key=os.path.getmtime) + with open(latest, "r", encoding="utf-8") as f: + content = f.read() + restored = _restore_text_pairs(content) or _restore_native_history(content) + if not restored: + return None, "❌ 历史记录里没有可恢复内容" + count = sum(1 for line in restored if line.startswith("[USER]: ")) + return (restored, os.path.basename(latest), count), None + + +def build_done_text(raw_text): + files = [p for p in extract_files(raw_text) if os.path.exists(p)] + body = strip_files(clean_reply(raw_text)) + if files: + body = (body + "\n\n" if body else "") + "\n".join(f"生成文件: {p}" for p in files) + return body or "..." + + +def public_access(allowed): + return not allowed or "*" in allowed + + +def to_allowed_set(value): + if value is None: + return set() + if isinstance(value, str): + value = [value] + return {str(x).strip() for x in value if str(x).strip()} + + +def allowed_label(allowed): + return "public" if public_access(allowed) else sorted(allowed) + + +def ensure_single_instance(port, label): + try: + lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + lock_sock.bind(("127.0.0.1", port)) + return lock_sock + except OSError: + print(f"[{label}] Another instance is already running, skipping...") + sys.exit(1) + + +def require_runtime(agent, label, **required): + missing = [k for k, v in required.items() if not v] + if missing: + print(f"[{label}] ERROR: please set {', '.join(missing)} in mykey.py or mykey.json") + sys.exit(1) + if agent.llmclient is None: + print(f"[{label}] ERROR: no usable LLM backend found in mykey.py or mykey.json") + sys.exit(1) + + +def redirect_log(script_file, log_name, label, allowed): + log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(script_file))), "temp") + os.makedirs(log_dir, exist_ok=True) + logf = open(os.path.join(log_dir, log_name), "a", encoding="utf-8", buffering=1) + sys.stdout = sys.stderr = logf + print(f"[NEW] {label} process starting, the above are history infos ...") + print(f"[{label}] allow list: {allowed_label(allowed)}") + + +class AgentChatMixin: + label = "Chat" + source = "chat" + split_limit = 1500 + ping_interval = 20 + + def __init__(self, agent, user_tasks): + self.agent, self.user_tasks = agent, user_tasks + + async def send_text(self, chat_id, content, **ctx): + raise NotImplementedError + + async def send_done(self, chat_id, raw_text, **ctx): + await self.send_text(chat_id, build_done_text(raw_text), **ctx) + + async def handle_command(self, chat_id, cmd, **ctx): + parts = (cmd or "").split() + op = (parts[0] if parts else "").lower() + if op == "/help": + return await self.send_text(chat_id, HELP_TEXT, **ctx) + if op == "/stop": + state = self.user_tasks.get(chat_id) + if state: + state["running"] = False + self.agent.abort() + return await self.send_text(chat_id, "⏹️ 正在停止...", **ctx) + if op == "/status": + llm = self.agent.get_llm_name() if self.agent.llmclient else "未配置" + return await self.send_text(chat_id, f"状态: {'🔴 运行中' if self.agent.is_running else '🟢 空闲'}\nLLM: [{self.agent.llm_no}] {llm}", **ctx) + if op == "/llm": + if not self.agent.llmclient: + return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", **ctx) + if len(parts) > 1: + try: + self.agent.next_llm(int(parts[1])) + return await self.send_text(chat_id, f"✅ 已切换到 [{self.agent.llm_no}] {self.agent.get_llm_name()}", **ctx) + except Exception: + return await self.send_text(chat_id, f"用法: /llm <0-{len(self.agent.list_llms()) - 1}>", **ctx) + lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in self.agent.list_llms()] + return await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), **ctx) + if op == "/restore": + try: + restored_info, err = format_restore() + if err: + return await self.send_text(chat_id, err, **ctx) + restored, fname, count = restored_info + self.agent.abort() + self.agent.history.extend(restored) + return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx) + except Exception as e: + return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx) + if op == "/continue": + return await self.send_text(chat_id, _handle_continue_frontend(self.agent, cmd), **ctx) + if op == "/new": + return await self.send_text(chat_id, _reset_conversation(self.agent), **ctx) + if op == "/btw": + answer = await asyncio.to_thread(_handle_btw_frontend, self.agent, cmd) + return await self.send_text(chat_id, answer, **ctx) + if op == "/review": + return await self.run_agent(chat_id, cmd, **ctx) + return await self.send_text(chat_id, HELP_TEXT, **ctx) + + async def run_agent(self, chat_id, text, **ctx): + state = {"running": True} + self.user_tasks[chat_id] = state + try: + await self.send_text(chat_id, "思考中...", **ctx) + dq = self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source) + last_ping = time.time() + while state["running"]: + try: + item = await asyncio.to_thread(dq.get, True, 3) + except Q.Empty: + if self.agent.is_running and time.time() - last_ping > self.ping_interval: + await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", **ctx) + last_ping = time.time() + continue + if "done" in item: + await self.send_done(chat_id, item.get("done", ""), **ctx) + break + if not state["running"]: + await self.send_text(chat_id, "⏹️ 已停止", **ctx) + except Exception as e: + import traceback + print(f"[{self.label}] run_agent error: {e}") + traceback.print_exc() + await self.send_text(chat_id, f"❌ 错误: {e}", **ctx) + finally: + self.user_tasks.pop(chat_id, None) + + +from agentmain import GeneraticAgent as _GA +from continue_cmd import handle_frontend_command as _handle_continue_frontend, install as _install_continue, reset_conversation as _reset_conversation +_install_continue(_GA) +from btw_cmd import handle_frontend_command as _handle_btw_frontend, install as _install_btw; _install_btw(_GA) +from review_cmd import install as _install_review; _install_review(_GA) diff --git a/frontends/conductor.html b/frontends/conductor.html new file mode 100644 index 0000000..5380d5c --- /dev/null +++ b/frontends/conductor.html @@ -0,0 +1,513 @@ + + + + + + Conductor + + + +
+
+ +
+ +
+
+
Subagents
+
点击卡片展开 · live
+
+
+
暂无 subagent。
+
+
+ + +
+
+ 🧠 Conductor 输出 + +
+
+
+
+ + +
+ +
+
+
待批任务 0
+
conductor 拟派 subagent · 待你批复
+
+
+
+ + +
+
+
Conversation
+
connecting...
+
+
+
+ + +
+
+
+
+
+ + + + + diff --git a/frontends/conductor.py b/frontends/conductor.py new file mode 100644 index 0000000..7b739e8 --- /dev/null +++ b/frontends/conductor.py @@ -0,0 +1,537 @@ +import os, sys, re, time, json, uuid, queue, asyncio, threading +from dataclasses import dataclass, field +from typing import Dict, Any, Optional, List +from contextlib import asynccontextmanager + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: sys.path.insert(0, ROOT) + +from agentmain import GenericAgent + +HOST = "127.0.0.1" +PORT = 8900 +HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "conductor.html") + + +def _desktop_llm_no() -> Optional[int]: + """Read the model index the user picked in the desktop UI. + Persisted by the bridge at ~/.ga_desktop_settings.json under ui.llmNo. + Returns None when unavailable, so callers keep the agent's default model.""" + try: + from pathlib import Path + doc = json.loads((Path.home() / ".ga_desktop_settings.json").read_text(encoding="utf-8")) + no = (doc.get("ui") or {}).get("llmNo") + return int(no) if no is not None else None + except Exception: + return None + + +def _apply_desktop_model(agent: "GenericAgent") -> None: + """Switch a freshly built agent to the desktop-selected model (if any).""" + no = _desktop_llm_no() + if no is None: + return + try: + agent.next_llm(int(no)) + except Exception as e: + print(f"[conductor] failed to apply desktop model #{no}: {e}", file=sys.stderr) + + +def _select_llm(agent: "GenericAgent", llm: Any) -> bool: + if llm is None or str(llm).strip() == "": return False + q = str(llm).strip() + if isinstance(llm, int) or q.isdigit(): agent.next_llm(int(q)); return True + q = q.lower(); agent.load_llm_sessions() + for i, c in enumerate(agent.llmclients): + vals = [] + for fn in (lambda: agent.get_llm_name(c), lambda: agent.get_llm_name(c, model=True), lambda: c.backend.name, lambda: c.backend.model): + try: vals.append(str(fn()).lower()) + except Exception: pass + if any(q in v for v in vals): agent.next_llm(i); return True + raise ValueError(f"llm not found: {llm}") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # 服务启动(事件循环已就绪):捕获 loop 供工作线程跨线程推 WS 广播,并起主agent + global main_loop + main_loop = asyncio.get_running_loop() + import cost_tracker; cost_tracker.install() + conductor.start() + threading.Thread(target=im_poll_loop, name="im-poller", daemon=True).start() + yield + + +app = FastAPI(title="Conductor", lifespan=lifespan) +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +class ChatIn(BaseModel): + msg: str + role: str = "conductor" # conductor | system | user + +class StartSubagentIn(BaseModel): + prompt: str + llm: Any = None + +class ApprovalIn(BaseModel): + prompt: str + source: str = "" + +class SubagentActionIn(BaseModel): + action: str = "intervene" # intervene | abort | kill + msg: str = "" + llm: Any = None + +@dataclass +class SubAgentState: + id: str + agent: GenericAgent + prompt: str + thread: Optional[threading.Thread] = None + reply: str = "" + status: str = "running" # running | stopped + created_at: int = field(default_factory=lambda: int(time.time())) + updated_at: int = field(default_factory=lambda: int(time.time())) + +ws_clients: set[WebSocket] = set() +main_loop: Optional[asyncio.AbstractEventLoop] = None +# conductor event queue: only user messages and subagent-done events enter here. +chat_messages: List[dict] = [] + +def now_ms() -> int: + return int(time.time() * 1000) + +def short_id() -> str: + return uuid.uuid4().hex[:8] + +_TURN_SPLIT_RE = re.compile(r'\**LLM Running \(Turn \d+\) \.\.\.\**') +_SUMMARY_RE = re.compile(r'(.*?)\s*', re.DOTALL) + +def extract_last_summary(full: str) -> str: + """Extract the latest content for in-progress display.""" + matches = _SUMMARY_RE.findall(full or "") + if not matches: return "" + s = matches[-1].strip() + return s[-1000:] if len(s) > 1000 else s + +def extract_last_text_reply(full: str) -> str: + """Extract only the last turn's text reply (like stapp.py fold_turns logic).""" + # Split by turn markers, take last segment + parts = _TURN_SPLIT_RE.split(full) + last = parts[-1] if parts else full + # Strip tags + last = _SUMMARY_RE.sub('', last) + # Strip [Status] and [Info] lines + last = re.sub(r'\[(Status|Info)\][^\n]*\n?', '', last) + # Strip trailing whitespace + last = last.strip() + # Cap length + return last[-3000:] if len(last) > 3000 else last + +def clean_log_text(s: str) -> str: + if not s: return s + s = re.sub(r'`{5}\n.*?`{5}\n?', '', s, flags=re.DOTALL) + s = re.sub(r'🛠️ Tool: `([^`]+)`\s*📥 args:\n`{4}.*?`{4}\n?', r'🛠️ `\1`\n', s, flags=re.DOTALL) + s = re.sub(r'^🛠️ .*\n?', '', s, flags=re.MULTILINE) # remove tool call summary lines + s = re.sub(r'.*?\s*', '', s, flags=re.DOTALL) + s = re.sub(r'^\s*\[(?:Info|Status)\][^\n]*\n?', '', s, flags=re.MULTILINE) + s = re.sub(r'^\s*`{4,5}\s*$\n?', '', s, flags=re.MULTILINE) + s = re.sub(r'\n{3,}', '\n\n', s) + return s.strip() + +def schedule_broadcast(payload: dict): + if main_loop and main_loop.is_running(): + asyncio.run_coroutine_threadsafe(broadcast(payload), main_loop) + +async def broadcast(payload: dict): + dead = [] + for ws in list(ws_clients): + try: await ws.send_json(payload) + except Exception: dead.append(ws) + for ws in dead: ws_clients.discard(ws) + +def push_cards(): schedule_broadcast({"type": "subagents", "items": pool.snapshot()}) + +def add_chat(msg: str, role: str = "conductor", files: list = None, images: list = None): + item = {"id": short_id(), "role": role, "msg": msg, "ts": now_ms(), "read": role != "user", "files": files or [], "images": images or []} + chat_messages.append(item) + if len(chat_messages) > 200: del chat_messages[:-200] + schedule_broadcast({"type": "chat", "item": item}) + return item + +def start_agent_runner(agent: GenericAgent, name: str): + t = threading.Thread(target=agent.run, name=name, daemon=True) + t.start(); return t + +def monitor_display_queue(agent_id: str, dq: "queue.Queue", trigger_when_done: bool): + acc = "" + while True: + item = dq.get() + if "next" in item: + chunk = item.get("next") or "" + acc += chunk + pool.on_display(agent_id, acc, done=False) + push_cards() + if "done" in item: + done = item.get("done") or acc + pool.on_display(agent_id, done, done=True) + push_cards() + if trigger_when_done: conductor.notify({"type": "subagent_done", "id": agent_id, "reply": done}) + break + + +class SubagentPool: + def __init__(self): + self.subagents: Dict[str, SubAgentState] = {} + self.lock = threading.RLock() + threading.Thread(target=self._auto_cleanup_loop, name="subagent-cleanup", daemon=True).start() + def snapshot(self) -> list[dict]: + with self.lock: + return [ + { + "id": s.id, + "prompt": s.prompt, + "reply": (extract_last_summary(s.reply) if s.status == "running" else extract_last_text_reply(s.reply)) if s.reply else "", + "status": s.status, + "created_at": s.created_at, + "updated_at": s.updated_at, + } + for s in self.subagents.values() + ] + def get(self, sid: str) -> Optional[SubAgentState]: + with self.lock: return self.subagents.get(sid) + def counts(self) -> tuple: + with self.lock: + running = sum(1 for s in self.subagents.values() if s.status == "running") + stopped = sum(1 for s in self.subagents.values() if s.status != "running") + return running, stopped + def on_display(self, agent_id: str, acc: str, done: bool): + with self.lock: + s = self.subagents.get(agent_id) + if s: + s.reply = acc + s.updated_at = int(time.time()) + s.status = "stopped" if done else "running" + def _auto_cleanup_loop(self): + IDLE_TIMEOUT = 3600 + while True: + time.sleep(300) + now = time.time() + to_abort = [] + with self.lock: + for sid, s in self.subagents.items(): + if s.status == "stopped" and (now - s.updated_at) > IDLE_TIMEOUT: to_abort.append((sid, s)) + for sid, s in to_abort: + s.agent.abort() + s.agent.task_queue.put("EXIT") + with self.lock: self.subagents.pop(sid, None) + if to_abort: push_cards() + def start_subagent(self, prompt: str, llm: Any = None) -> dict: + sid = short_id() + agent = GenericAgent() + agent.inc_out = True + agent.verbose = False + agent.no_print = True + if not _select_llm(agent, llm): _apply_desktop_model(agent) + th = start_agent_runner(agent, f"subagent-{sid}") + state = SubAgentState(id=sid, agent=agent, prompt=prompt, status="running", thread=th) + with self.lock: self.subagents[sid] = state + return self._send_msg(sid, prompt) + def _send_msg(self, sid, msg): + with self.lock: s = self.subagents.get(sid) + if not s: return {"error": "subagent not found", "id": sid} + dq = s.agent.put_task(msg, source=f"subagent:{sid}") + threading.Thread(target=monitor_display_queue, args=(sid, dq, True), name=f"monitor-{sid}", daemon=True).start() + push_cards() + return {"id": sid, "status": "running"} + def input_subagent(self, sid: str, msg: str, llm: Any = None) -> dict: + with self.lock: s = self.subagents.get(sid) + if not s: return {"error": "subagent not found", "id": sid} + if s.status == "running": return {"error": "subagent is still running, cannot input/reply. Start a new subagent instead.", "id": sid} + _select_llm(s.agent, llm) + s.prompt = msg + s.reply = "" + s.status = "running" + s.updated_at = int(time.time()) + return self._send_msg(sid, msg) + def keyinfo_subagent(self, sid: str, msg: str) -> dict: + with self.lock: s = self.subagents.get(sid) + if not s: return {"error": "subagent not found", "id": sid} + h = s.agent.handler + h.working['key_info'] = h.working.get('key_info', '') + f"\n[MASTER] {msg}" + s.updated_at = int(time.time()) + return {"id": sid, "status": "keyinfo_injected"} + +pool = SubagentPool() + +READMES = { +"api": f"""\ +Conductor API\tBase: http://{HOST}:{PORT} + +POST /chat\tbody: {{"msg": "..."}}\t给用户发消息 +POST /subagent\tbody: {{"prompt": "..."}}\t启动新subagent,返回 {{"id": "xxx"}};指定模型加参数llm(数字/名称) +POST /approval\tbody: {{"prompt": "...", "source": "..."}}\t推一条待批任务到前端(后端不存),用户同意则直接派发为subagent +POST /subagent/{{id}}\tbody: {{"action": "keyinfo", "msg": "..."}}\t注入key_info(agent下轮可见) +POST /subagent/{{id}}\tbody: {{"action": "input", "msg": "..."}}\t开新一轮任务;指定模型加参数llm(数字/名称) +POST /subagent/{{id}}\tbody: {{"action": "stop"}}\t中断执行但保留(可继续input/reply) +GET /chat?last=N\t返回最近N条对话(默认20) +GET /subagent\t返回 {{"items": [...]}}\t查看所有subagent状态 +GET /subagent/{{id}}?max_len=N\t返回单个subagent详情,reply经清洗后截取尾部max_len字(默认5000)。仅在摘要不够判断时使用 +""", +"usermsg": """\ +用户消息流程: +1. 结合记忆、上下文和用户偏好判断真实需求;不清楚/不能代劳时,用精简checklist一次性问用户。 +2. 判断是新任务还是延续现有任务;优先复用已有stopped subagent(用input追加),只有确实无关的新任务才新建。 +3. 分派前必须POST /chat告知用户:改写后的prompt + 分派方案(新建/复用哪个subagent)。 +4. 执行分派,完成即停。危险操作(改源码/删数据/安全敏感)必须改成先让subagent出方案;你验收后POST /chat请用户确认,确认后才继续执行。""", +"subagent": """\ +subagent完成流程: +1. 如果是IM采集subagent,按GET /readme/im进行而非本流程 +2. 读subagent输出;若最后一条不足以判断,GET /subagent/{id}?max_len=3000 补足信息。 +3. 预测用户是否满意;不满意就reply/keyinfo要求返工、修改、优化,继续监督,不急着报告。 +4. 预计用户满意后,POST /chat给简洁交付报告。""", +"im": """\ +你要审查IM采集subagent的输出,把**值得用户关注的内容**报告给用户或转化成"可点击执行"的待批TODO(approval)。 +先读L2记忆中User相关,推荐的动作和措辞要符合用户画像。 +要求: +1. 不要只凭采集摘要;重要事实要核实,需要判断时先派subagent补做必要调查,再下结论。 +2. 没有值得用户点击执行的动作就直接结束,不要打扰;尤其不要对执行回执/完成确认/纯闲聊报"无需关注"。 +3. 判断标准:私聊默认重要,群聊除非@用户否则忽略。 +4. 只有真正需要用户的内容才报告或形成TODO。不要推"去看看/研究一下"这种半成品。TODO必须是最后一步可直接执行的动作(发某段微信回复、回复某封邮件草稿、处理某PR、整理某文件等)。 +5. 如果形成用户TODO,POST /approval 推送,prompt里同时写清两部分: + ① 奏折式报告给用户拍板:背景(什么事/来自谁) + 已核实(你做了哪些调查/关键事实) + 判断(为什么这样建议) + 风险。用户看完这段就能直接拍板,不用再去翻原消息。 + ② 用户同意后该执行的完整任务指令(approval通过会直接作为subagent的prompt派发,必须具体到可直接执行)。""", +} + +class Conductor: + LOG_MAX = 50 + + def __init__(self): + self.inbox: "queue.Queue[dict]" = queue.Queue() # 收件箱:唯一对外接口 + self.agent: Optional[GenericAgent] = None + self.started = False + self.log: list = [] + + def notify(self, event: dict): self.inbox.put(event) + + def _build_prompt(self, events: list) -> str: + running, stopped = pool.counts() + unread = sum(1 for m in chat_messages if m.get("role") == "user" and not m.get("read")) + done_count = sum(1 for e in events if e.get("type") == "subagent_done") + event_type = events[0].get("type") if events else "wake"; im_sources = [e.get("source") for e in events if e.get("type") == "im_signal"] + if event_type == "user_message": summary = f"[用户消息] {unread}条未读用户消息,GET /chat 读取;按GET /readme/usermsg处理。" + elif event_type == "subagent_done": summary = f"[subagent完成] {done_count}个完成报告;GET /subagent 查看并验收;IM subagent完成报告按GET /readme/im处理,其他subagent完成报告按GET /readme/subagent处理。" + elif event_type == "im_signal": summary = f"[IM信号] {', '.join(im_sources)} 有新消息;" + ";".join(f"GET /im_prompt/{s}取采集prompt" for s in im_sources) + ";尽量复用已有subagent。" + else: summary = f"[唤醒] subagents: {running} running, {stopped} stopped | {unread}条用户未读消息, {done_count}个subagent完成报告" + base = f"http://{HOST}:{PORT}" + return f"""你是agent总管。用户只和你对话,你负责调度、验收、交付,目标是降低用户管理多个agent的负担。 +API: {base};requests,GET /readme查用法,GET /chat读未读对话,GET /subagent看状态;POST /chat是唯一对用户说话方式。 + +铁律: +- 绝不亲自执行任务/探测环境;一切执行交给subagent。你只分析、派遣、审查、沟通。 +- 每次唤醒只做最小必要动作(发消息/开subagent/reply/keyinfo/abort),做完立刻停,等待下次事件唤醒。 +- 改写prompt时严禁添加用户未提及的假设、工具、前提条件。只能精炼/结构化用户原意,不能脑补,只能做很小的改写 + +原则: +- 信任subagent足够聪明,不要写具体步骤和容易探测的信息;能自己判断的自己判断,只在真正需要用户决策时打扰。\n +需要处理: +{summary}""" + + def _drain(self, dq: "queue.Queue", events: list) -> str: + event_label = ",".join(e.get("type", "") for e in events) or "wake" + cur_turn = None; buf = "" + + def flush(): + nonlocal buf + cleaned = clean_log_text(buf) + if cleaned: + item = {"id": short_id(), "ts": now_ms(), "event": event_label, + "turn": cur_turn, "text": cleaned} + self.log.append(item) + if len(self.log) > self.LOG_MAX: self.log.pop(0) + schedule_broadcast({"type": "log", "item": item}) + buf = "" + + while True: + item = dq.get() + if "next" in item: + t = item.get("turn") + if cur_turn is None: cur_turn = t + elif t != cur_turn: + flush(); cur_turn = t + buf += item.get("next", "") or "" + elif "done" in item: + if cur_turn is None: cur_turn = item.get("turn") + flush() + print("Conductor task done") + return + + def _run(self): + self.agent = GenericAgent() + self.agent.inc_out = True + start_agent_runner(self.agent, "conductor-agent") + self.started = True + while True: + # Block until first event arrives + first = self.inbox.get() + self.inbox.task_done() + # Short debounce: collect any additional events that arrived meanwhile + time.sleep(0.3) + events = [first] + while not self.inbox.empty(): + try: + events.append(self.inbox.get_nowait()) + self.inbox.task_done() + except Exception: + break + try: + prompt = self._build_prompt(events) + # Follow the desktop-selected model live: re-read before each task + # so switching models in the UI takes effect without restarting. + _apply_desktop_model(self.agent) + dq = self.agent.put_task(prompt, source="conductor") + self._drain(dq, events) + except Exception as e: print(f"Conductor error: {e}") + + def start(self): threading.Thread(target=self._run, name="conductor-loop", daemon=True).start() + + +conductor = Conductor() + +# ---- IM poller: 探测conductor_im_plugins/下各插件,信号变化→唤醒总管 ---- +IM_DIR, IM_COOLDOWN = os.path.join(os.path.dirname(__file__), "conductor_im_plugins"), 300 +IM_PROMPTS: Dict[str, str] = {} # source -> 采集prompt(派采集subagent时按需取) + +def im_poll_loop(): + import importlib.util + mods, last_fire = {}, {} + for f in (x for x in os.listdir(IM_DIR) if x.endswith(".py") and not x.startswith("_")): + spec = importlib.util.spec_from_file_location(f[:-3], os.path.join(IM_DIR, f)) + m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) + if hasattr(m, "check"): + mods[f[:-3]] = m + IM_PROMPTS[f[:-3]] = getattr(m, "PROMPT", "") + last_check = {} + while True: + time.sleep(10) + for name, m in mods.items(): + now = time.time() + if now - last_check.get(name, 0) < getattr(m, "INTERVAL", 30): continue + last_check[name] = now + try: + if not m.check() or now - last_fire.get(name, 0) < IM_COOLDOWN: continue + except Exception: continue + last_fire[name] = now + conductor.notify({"type": "im_signal", "source": name}) + +@app.get("/token-stats") +def conductor_token_stats(): + import cost_tracker + return {"records": [{"thread": k, "input": v.input, "output": v.output, "cacheCreate": v.cache_create, "cacheRead": v.cache_read} for k, v in cost_tracker.all_trackers().items()]} + +@app.get("/") +def index(): return FileResponse(HTML_PATH) + +@app.get("/readme") +def readme(): return PlainTextResponse(READMES["api"]) + +@app.get("/readme/{topic}") +def readme_topic(topic: str): + if topic not in READMES: + return PlainTextResponse(f"Unknown topic: {topic}. Available: {', '.join(READMES.keys())}", status_code=404) + return PlainTextResponse(READMES[topic]) + +@app.get("/im_prompt/{source}") +def im_prompt(source: str): + if source not in IM_PROMPTS: + return PlainTextResponse(f"Unknown source: {source}. Available: {', '.join(IM_PROMPTS.keys())}", status_code=404) + return PlainTextResponse(IM_PROMPTS[source]) + +@app.get("/subagent") +def list_subagents(): return {"items": pool.snapshot()} + +@app.get("/subagent/{sid}") +def get_subagent(sid: str, max_len: int = 5000): + s = pool.get(sid) + if not s: + return JSONResponse({"error": "not found"}, status_code=404) + cleaned = clean_log_text(s.reply or "") + return {"id": s.id, "prompt": s.prompt, "status": s.status, + "reply": cleaned[-max_len:] if len(cleaned) > max_len else cleaned, + "created_at": s.created_at, "updated_at": s.updated_at} + +INSTR_DISPATCHED = "Task received. I'll handle THIS TASK from here. You MUST to do other task or end your reply." + +@app.post("/subagent") +def api_start_subagent(body: StartSubagentIn): + result = pool.start_subagent(body.prompt, body.llm) + result["instruction"] = INSTR_DISPATCHED + return result + +@app.post("/subagent/{sid}") +def api_subagent_action(sid: str, body: SubagentActionIn): + s = pool.get(sid) + if not s: return JSONResponse({"error": "subagent not found", "id": sid}, status_code=404) + action = body.action.lower().strip() + if action == "keyinfo": + result = pool.keyinfo_subagent(sid, body.msg) + result["instruction"] = "Received. I'll incorporate this. You MUST to do other task or end your reply." + return result + if action in ("input", "reply", "append", "message", "msg"): + result = pool.input_subagent(sid, body.msg, body.llm) + result["instruction"] = INSTR_DISPATCHED + return result + if action in ("abort", "stop"): + s.agent.abort() + s.status = "stopped" + s.updated_at = int(time.time()) + push_cards() + return {"id": sid, "status": "stopped"} + return JSONResponse({"error": f"unknown action: {body.action}"}, status_code=400) + +@app.get("/chat") +def api_get_chat(last: int = 20): + items = [m.copy() for m in chat_messages[-last:]] + for m in chat_messages: + if m.get("role") == "user" and not m.get("read"): m["read"] = True + schedule_broadcast({"type": "chat_read"}) + return {"items": items} + +@app.post("/chat") +def api_chat(body: ChatIn): + return add_chat(body.msg, role=body.role) + +@app.post("/approval") +def api_approval(body: ApprovalIn): + schedule_broadcast({"type": "approval", "item": {"id": short_id(), "prompt": body.prompt, "source": body.source}}) + return {"ok": True} + +@app.websocket("/ws") +async def websocket(ws: WebSocket): + await ws.accept() + ws_clients.add(ws) + try: + running = any(s.status == "running" for s in pool.subagents.values()) + await ws.send_json({"type": "hello", "subagents": pool.snapshot(), "chat": chat_messages, "log": conductor.log, "running": running}) + while True: + data = await ws.receive_json() + msg = (data.get("msg") or "").strip() + if not msg: continue + add_chat(msg, role="user", files=data.get("files") or [], images=data.get("images") or []) + conductor.notify({"type": "user_message", "msg": msg}) + except WebSocketDisconnect: pass + finally: ws_clients.discard(ws) + +if __name__ == "__main__": + import uvicorn + # bridge 自启 conductor 时传 --no-browser:不在用户浏览器里弹一个独立 conductor UI, + # 用户从桌面版「指挥家」页直接连过来即可。手动跑 conductor.py(没带 flag)保持原行为。 + if "--no-browser" not in sys.argv: + import webbrowser, threading + threading.Timer(1.0, lambda: webbrowser.open(f"http://{HOST}:{PORT}")).start() + uvicorn.run("conductor:app", host=HOST, port=PORT, reload=False) diff --git a/frontends/continue_cmd.py b/frontends/continue_cmd.py new file mode 100644 index 0000000..7588980 --- /dev/null +++ b/frontends/continue_cmd.py @@ -0,0 +1,1181 @@ +"""`/continue` command: list & restore past model_responses sessions. +Pure functions + one `install(cls)` monkey-patch entry. No side effects at import. +""" +import ast, atexit, glob, json, os, random, re, shutil, threading, time +_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'temp', 'model_responses') +_LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt') +_BLOCK_RE = re.compile(r'^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)', + re.DOTALL | re.MULTILINE) +_SUMMARY_RE = re.compile(r'\s*(.*?)\s*', re.DOTALL) +_ROUND_HEADER_RE = re.compile(rb'^=== (Prompt|Response) ===', re.MULTILINE) +_ROUNDS_CACHE_PATH = os.path.join(os.path.expanduser('~'), '.genericagent', 'continue_rounds_cache.json') +_ROUNDS_CACHE_VERSION = 1 +_rounds_cache = None +_rounds_cache_dirty = False + +def _rel_time(mtime): + d = int(time.time() - mtime) + if d < 60: return f'{d}秒前' + if d < 3600: return f'{d // 60}分前' + if d < 86400: return f'{d // 3600}小时前' + return f'{d // 86400}天前' + +def _pairs(content): + blocks, pairs, pending = _BLOCK_RE.findall(content or ''), [], None + for label, body in blocks: + if label == 'Prompt': pending = body.strip() + elif pending is not None: + pairs.append((pending, body.strip())); pending = None + return pairs + +def _first_user(pairs): + for p, _ in pairs: + try: msg = json.loads(p) + except Exception: continue + if not isinstance(msg, dict): continue + for blk in msg.get('content', []) or []: + if isinstance(blk, dict) and blk.get('type') == 'text': + t = strip_project_mode(blk.get('text') or '').strip() + if t and '' not in t and not t.startswith('### [WORKING MEMORY]'): + return t + for p, _ in pairs[:1]: + for line in p.splitlines(): + s = line.strip() + if s and not s.startswith('###'): return s + return '' + + +def _last_user(text): + """Last real user prompt. Scans `=== Prompt ===` blocks directly (no + Prompt/Response pairing, so response-less/aborted sessions still preview), + newest-first, returning the first one `_user_text` accepts (it drops + tool_result continuations + all _INJECT_MARKERS). Better preview anchor than + the first prompt — reflects what the session was most recently about.""" + for label, body in reversed(_BLOCK_RE.findall(text or '')): + if label == 'Prompt': + t = _user_text(body) + if t: + return t + return '' + + +def _last_summary(pairs): + for _, response_body in reversed(pairs): + try: + blocks = ast.literal_eval(response_body) + except Exception: + continue + if not isinstance(blocks, list): + continue + text_parts = [] + for block in blocks: + if isinstance(block, dict) and block.get('type') == 'text': + text = block.get('text', '') + if isinstance(text, str) and text: + text_parts.append(text) + match = _SUMMARY_RE.search('\n'.join(text_parts)) + if match: + summary = match.group(1).strip() + if summary: + return summary + return '' + + +def _preview_text(pairs): + return _last_summary(pairs) or _first_user(pairs) + +def _recent_context(my_pid, n=5): + """扫描最近 n 个 model_response 文件(排除自身),提取 lastQ / lastA。""" + out = [] + for f in sorted(glob.glob(_LOG_GLOB), key=os.path.getmtime, reverse=True): + m = re.search(r'model_responses_(\d+)', os.path.basename(f)) + if not m or m.group(1) == str(my_pid): continue + try: c = open(f, encoding='utf-8', errors='ignore').read() + except Exception: continue + q = s = "" + for hm in re.finditer(r'(.*?)', c, re.DOTALL): + u = re.search(r'\[USER\]:\s*(.+?)(?:\\n|<)', hm.group(1)) + if u: q = u.group(1) + sm = _SUMMARY_RE.search(c) + if sm: s = sm.group(1).strip() + q, s = q[:60].strip(), s[:60].replace('\n', ' ').strip() + out.append(f'· {m.group(1)} | lastQ: {q or "-"} | lastA: {s or "-"}') + if len(out) >= n: break + return ('[RecentContext] 近期并行会话(非当前):\n' + '\n'.join(out) + '\n[/RecentContext]') if out else "" + +def _parse_native_history(pairs): + history = [] + for p, r in pairs: + try: user_msg = json.loads(p) + except Exception: return None + try: blocks = ast.literal_eval(r) + except Exception: return None + if not (isinstance(user_msg, dict) and user_msg.get('role') == 'user'): return None + if not isinstance(blocks, list): return None + history.append(user_msg) # runtime history 尊重日志真实 prompt(含 project-mode 当轮注入) + history.append({'role': 'assistant', 'content': blocks}) + return history + + +def parse_native_log(path, allow_empty=False): + """Parse a native `model_responses_*.txt` log into backend.history. + + Public wrapper around the mature `/continue` parser. It intentionally only + restores complete Prompt→Response pairs; dangling Prompt / partial turns keep + the current continue semantics and are ignored. Returns: + - list (possibly [] when allow_empty=True) on native success; + - None when the file is unreadable / non-native / empty without allow_empty. + """ + try: + with open(path, encoding='utf-8', errors='replace') as fh: + content = fh.read() + except Exception: + return [] if allow_empty else None + pairs = _pairs(content) + if not pairs: + # Native-looking but incomplete logs (e.g. dangling Prompt without Response) + # have block headers but no complete pairs. For worldline's allow_empty mode + # this means "0 completed rounds", not "parse failed → trust live history". + if allow_empty and (_is_empty_log(path) or _BLOCK_RE.findall(content or '')): + return [] + return None + return _parse_native_history(pairs) + + +def _derive_hist_info(history): + """从 native history 重建 history_info(轮级纪要):真实用户提问 → `[USER]: …`;每条 + assistant(=一轮) → `[Agent] `(无 summary 取首行)。与 ga.turn_end_callback / + worldline 树同口径。纯函数、不依赖 worldline,供续接 opt-in 恢复工作记忆(见 restore_wm)。""" + def _all_text(m): + c = m.get('content') if isinstance(m, dict) else None + if isinstance(c, str): return c + if isinstance(c, list): + return '\n'.join(b.get('text', '') for b in c + if isinstance(b, dict) and b.get('type') == 'text') + return '' + def _is_tool_result(m): + c = m.get('content') if isinstance(m, dict) else None + return isinstance(c, list) and any( + isinstance(b, dict) and b.get('type') == 'tool_result' for b in c) + out = [] + for m in history or []: + if not isinstance(m, dict): continue + role = m.get('role') + if role == 'user': + if _is_tool_result(m): continue # tool_result 轮不是提问 + t = strip_project_mode(_all_text(m)).strip() + if t and not t.startswith(_INJECT_MARKERS): + out.append(f'[USER]: {t}') + elif role == 'assistant': + txt = re.sub(r'```.*?```|.*?', '', _all_text(m), flags=re.DOTALL) + mt = re.search(r'(.*?)', txt, re.DOTALL) + s = mt.group(1).strip()[:80] if (mt and mt.group(1).strip()) else '' + if not s: + s = next((ln.strip()[:80] for ln in txt.splitlines() if ln.strip()), '(无摘要)') + out.append(f'[Agent] {s}') + return out + + +_PREVIEW_WIN = 32 * 1024 + +# Content-grep budget for `/continue` search box: read at most this many bytes +# per session (head window) so 17MB files don't stall the UI. Empirically the +# user-typed prompt + first model reply + early summaries live in the first MB, +# which is what users actually want to recall sessions by. +_GREP_WIN = 1 * 1024 * 1024 + + +def file_contains_all(path, terms, max_bytes=_GREP_WIN): + """True iff every lowercase term in `terms` appears in the first + `max_bytes` of `path` (case-insensitive). Empty `terms` returns True so + callers can short-circuit. Reads as bytes + .lower() to avoid utf-8 cost + and stays within a fixed memory envelope regardless of file size. + """ + if not terms: + return True + try: + with open(path, 'rb') as fh: + buf = fh.read(max_bytes) + except OSError: + return False + if not buf: + return False + hay = buf.lower() + for t in terms: + if t and t.encode('utf-8', errors='ignore') not in hay: + return False + return True + + +def search_sessions(query, sessions, max_bytes=_GREP_WIN): + """Filter `sessions` ([(path, mtime, preview, n), ...]) by content grep. + + `query` is whitespace-split into AND terms (case-insensitive). Each + session is kept iff its path/preview already match OR the first + `max_bytes` of its file contain every term. Order is preserved. + Empty/whitespace query returns the list as-is. + """ + q = (query or '').strip().lower() + if not q: + return list(sessions or []) + terms = [t for t in q.split() if t] + if not terms: + return list(sessions or []) + out = [] + for item in sessions or []: + path = item[0] if len(item) > 0 else '' + preview = item[2] if len(item) > 2 else '' + meta = (os.path.basename(path) + '\n' + (preview or '')).lower() + if all(t in meta for t in terms): + out.append(item) + continue + if file_contains_all(path, terms, max_bytes=max_bytes): + out.append(item) + return out + + +def _preview_from_file(path): + """Cheap preview: last in tail window, else first user line in head window.""" + try: + sz = os.path.getsize(path) + with open(path, 'rb') as fh: + if sz <= _PREVIEW_WIN * 2: + head = tail = fh.read() + else: + head = fh.read(_PREVIEW_WIN) + fh.seek(-_PREVIEW_WIN, 2); tail = fh.read() + except OSError: return '' + tail_s = tail.decode('utf-8', errors='replace') + # Use only the latest , and reject it if dirty. Models sometimes emit + # an unclosed , so the non-greedy DOTALL match pairs it with a far-away + # and swallows === block headers / JSON across rounds. Treat such a + # match as invalid and fall through to the last user prompt (don't dig older ones). + cands = _SUMMARY_RE.findall(tail_s) + if cands: + s = ' '.join(cands[-1].split()) + if s and '=== ' not in s and '"role"' not in s and len(s) <= 200: + return s + # Summary invalid/absent -> last real user prompt (JSON-aware, skips anchors; + # scans Prompt blocks directly so response-less sessions still preview). + lu = _last_user(tail_s) or _last_user(head.decode('utf-8', errors='replace')) + if lu: + return ' '.join(lu.split())[:120] + return '' + + +def _rounds_cache_key(path): + return os.path.normcase(os.path.abspath(path)) + + +def _load_rounds_cache(): + """Load lazy mtime/size keyed round-count cache for /continue. + + Cache is intentionally triggered only by list_sessions(): no TUI startup cost, + no logging-path coupling. Missing/stale entries are recomputed on demand. + """ + global _rounds_cache + if _rounds_cache is not None: + return _rounds_cache + _rounds_cache = {} + try: + with open(_ROUNDS_CACHE_PATH, encoding='utf-8') as fh: + data = json.load(fh) + if isinstance(data, dict) and data.get('version') == _ROUNDS_CACHE_VERSION: + items = data.get('items') + if isinstance(items, dict): + _rounds_cache = items + except Exception: + _rounds_cache = {} + return _rounds_cache + + +def _save_rounds_cache(valid_keys=None): + global _rounds_cache_dirty + if not _rounds_cache_dirty or _rounds_cache is None: + return + try: + if valid_keys is not None: + keep = set(valid_keys) + for k in list(_rounds_cache.keys()): + if k not in keep: + _rounds_cache.pop(k, None) + os.makedirs(os.path.dirname(_ROUNDS_CACHE_PATH), exist_ok=True) + tmp = _ROUNDS_CACHE_PATH + '.tmp' + data = {'version': _ROUNDS_CACHE_VERSION, 'items': _rounds_cache} + with open(tmp, 'w', encoding='utf-8') as fh: + json.dump(data, fh, ensure_ascii=False, separators=(',', ':')) + os.replace(tmp, _ROUNDS_CACHE_PATH) + _rounds_cache_dirty = False + except Exception: + # Cache is a performance hint only; never break /continue on cache I/O. + pass + + +def _count_complete_rounds_from_file(path): + """Count completed Prompt→Response pairs using only block headers. + + Counting Prompt headers alone overcounts an in-flight/incomplete last round. + Header-pair counting matched `_pairs()` on sampled real logs while avoiding + expensive UTF-8 decode / body regex parsing. + """ + try: + with open(path, 'rb') as fh: + data = fh.read() + except OSError: + return 0 + pending = False + rounds = 0 + for m in _ROUND_HEADER_RE.finditer(data): + if m.group(1) == b'Prompt': + pending = True + elif pending: + rounds += 1 + pending = False + return rounds + + +def _rounds_for_file(path, st): + global _rounds_cache_dirty + cache = _load_rounds_cache() + key = _rounds_cache_key(path) + size = int(getattr(st, 'st_size', 0)) + mtime_ns = int(getattr(st, 'st_mtime_ns', int(getattr(st, 'st_mtime', 0) * 1_000_000_000))) + ent = cache.get(key) + if isinstance(ent, dict) and ent.get('size') == size and ent.get('mtime_ns') == mtime_ns: + try: + return int(ent.get('rounds', 0)), key + except Exception: + pass + n = _count_complete_rounds_from_file(path) + cache[key] = {'size': size, 'mtime_ns': mtime_ns, 'rounds': int(n)} + _rounds_cache_dirty = True + return n, key + + +def list_sessions(exclude_pid=None, exclude_log=None, rewind_root=None): + """Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only. + + `exclude_log` (basename, e.g. 'model_responses_123456.txt') drops the caller's + OWN current session — preferred over `exclude_pid`, which assumed the log file + was named by PID (it isn't: agentmain mints a random 6-digit logid), so the + pid tag never matched and the current session leaked into its own list.""" + files = glob.glob(_LOG_GLOB) + if exclude_pid is not None: + tag = f'model_responses_{exclude_pid}.txt' + files = [f for f in files if not f.endswith(tag)] + if exclude_log: + files = [f for f in files if os.path.basename(f) != exclude_log] + out = [] + valid_keys = [] + for f in files: + try: + st = os.stat(f) + mtime, sz = st.st_mtime, st.st_size + except OSError: + continue + if sz < 32: + continue + preview = _preview_from_file(f) + if not preview: + continue + rounds, key = _rounds_for_file(f, st) + valid_keys.append(key) + out.append((f, mtime, preview, rounds)) + _save_rounds_cache(valid_keys) + # 【门控·worldline】树感知发现:日志存在但为空且有非空世界线树的会话(回退到起点后日志被 + # 清空 → 上面 sz<32 跳过了)。仅当调用方显式传 rewind_root 时启用 → 其他 UI 不传, + # 行为逐字节不变。只读 tree.json 的 nodes/head(不依赖 worldline 模块)。 + if rewind_root and os.path.isdir(rewind_root): + have = {os.path.basename(p) for p, *_ in out} + try: + keys = os.listdir(rewind_root) + except OSError: + keys = [] + for key in keys: + if not key.startswith('model_responses_'): + continue + log_name = key + '.txt' + if log_name in have or log_name == exclude_log: + continue + log_path = os.path.join(_LOG_DIR, log_name) + try: # 仅收"日志确实为空"的(非空日志已被主循环收录) + if os.path.getsize(log_path) >= 32: + continue + except OSError: + continue # 日志缺失(如已归档)不作为可续会话展示 + try: + with open(os.path.join(rewind_root, key, 'tree.json'), encoding='utf-8') as fh: + d = json.load(fh) + except Exception: + continue + nodes = d.get('nodes') or {} + real = [v for v in nodes.values() if v.get('kind') != 'origin'] + if not real: # 只有 origin 的空树 → 无内容,跳过 + continue + try: + mtime = os.path.getmtime(os.path.join(rewind_root, key, 'tree.json')) + except OSError: + mtime = 0 + head = d.get('head') + title = (nodes.get(head, {}).get('title') if head else '') or '(已回退至会话起点)' + out.append((log_path, mtime, f'[世界线] {title}', len(real))) + out.sort(key=lambda x: x[1], reverse=True) + return out +_MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])') +def _escape_md(s): return _MD_ESCAPE_RE.sub(r'\\\1', s) + + +def _agent_clients(agent): + clients = [] + for client in getattr(agent, 'llmclients', []) or []: + if client not in clients: + clients.append(client) + current = getattr(agent, 'llmclient', None) + if current is not None and current not in clients: + clients.insert(0, current) + return clients + + +def _replace_backend_history(agent, history): + backend = getattr(getattr(agent, 'llmclient', None), 'backend', None) + if backend is not None and hasattr(backend, 'history'): + backend.history = list(history or []) + + +def _current_log_path(pid=None): + pid = os.getpid() if pid is None else pid + return os.path.join(_LOG_DIR, f'model_responses_{pid}.txt') + + +def _snapshot_current_log(pid=None): + """Persist current PID log as a standalone recoverable snapshot, then clear it.""" + path = _current_log_path(pid) + if not os.path.isfile(path): + return None + try: + with open(path, encoding='utf-8', errors='replace') as fh: + content = fh.read() + except Exception: + return None + if not _pairs(content): + return None + os.makedirs(_LOG_DIR, exist_ok=True) + pid = os.getpid() if pid is None else pid + stamp = time.strftime('%Y%m%d_%H%M%S') + snapshot = os.path.join(_LOG_DIR, f'model_responses_snapshot_{pid}_{stamp}_{time.time_ns() % 1_000_000_000:09d}.txt') + with open(snapshot, 'w', encoding='utf-8', errors='replace') as fh: + fh.write(content) + with open(path, 'w', encoding='utf-8', errors='replace'): + pass + return snapshot + + +def reset_conversation(agent, message='🆕 已开启新对话,当前上下文已清空'): + """Abort current work and clear all known frontend-visible conversation state.""" + try: + agent.abort() + except Exception: + pass + _snapshot_current_log() + if hasattr(agent, 'history'): + agent.history = [] + for client in _agent_clients(agent): + backend = getattr(client, 'backend', None) + if backend is not None and hasattr(backend, 'history'): + backend.history = [] + if hasattr(client, 'last_tools'): + client.last_tools = '' + if hasattr(agent, 'handler'): + agent.handler = None + return message + +def format_list(sessions, limit=20): + if not sessions: return '❌ 没有可恢复的历史会话' + lines = ['**可恢复会话**(输入 `/continue N` 恢复第 N 个):', ''] + for i, (_, mtime, first, n) in enumerate(sessions[:limit], 1): + preview = _escape_md((first or '(无法预览)').replace('\n', ' ')[:60]) + lines.append(f'{i}. `{_rel_time(mtime)}` · **{n} 轮** · {preview}') + return '\n'.join(lines) + +def restore(agent, path): + """Restore session at path. Returns (msg, is_full).""" + try: + with open(path, encoding='utf-8', errors='replace') as fh: + content = fh.read() + except Exception as e: return f'❌ 读取失败: {e}', False + pairs = _pairs(content) + if not pairs: return f'❌ {os.path.basename(path)} 为空或格式不符', False + history = _parse_native_history(pairs) + name = os.path.basename(path) + if history is not None: + agent.abort() + _replace_backend_history(agent, history) + return f'✅ 已恢复 {len(pairs)} 轮完整对话({name})\n(已写入 backend.history,可直接继续)', True + from chatapp_common import _restore_native_history, _restore_text_pairs + summary = _restore_text_pairs(content) or _restore_native_history(content) + if not summary: return f'❌ {name} 无法解析(非 native 且无摘要可提取)', False + agent.abort() + agent.history.extend(summary) + n = sum(1 for l in summary if l.startswith('[USER]: ')) + return f'⚠️ 非 native 格式,已降级恢复 {n} 轮摘要({name})\n(请输入新问题继续)', False + +def handle(agent, query, display_queue): + """Dispatch /continue or /continue N. Returns None if consumed else original query.""" + s = (query or '').strip() + if s == '/continue': + display_queue.put({'done': format_list(list_sessions(exclude_pid=os.getpid())), 'source': 'system'}) + return None + m = re.match(r'/continue\s+(\d+)\s*$', s) + if m: + sessions = list_sessions(exclude_pid=os.getpid()) + idx = int(m.group(1)) - 1 + if not (0 <= idx < len(sessions)): + display_queue.put({'done': f'❌ 索引越界(有效范围 1-{len(sessions)})', 'source': 'system'}) + return None + reset_conversation(agent, message=None) + msg, _ = restore(agent, sessions[idx][0]) + display_queue.put({'done': msg, 'source': 'system'}) + return None + return query + + +_INJECT_MARKERS = ('### [WORKING MEMORY]', '[SYSTEM TIPS]', '[SYSTEM]', '[System]', + '[DANGER]', '### [总结提炼经验]', + 'Continue from where you left off') + +# project_mode 插件把 `\n\n---\n[PROJECT MODE: ]\n…\n---` 追加到当轮 user +# message(见 plugins/project_mode._build_injection)。runtime history 尊重日志真实 prompt, +# 但 UI 预览/显示与派生 history_info 需要只取用户原话。老日志的 WORKING MEMORY 里还 +# 可能嵌着已污染的 `[USER]: ... [PROJECT MODE] ... [Agent] ...`,所以剥离支持 text +# 内任意位置与被截断的单行 title 形态。不能加进 _INJECT_MARKERS——那会把整条用户原话一起丢弃。 +_PM_BLOCK_RE = re.compile(r"\s*-{3,}\s*\[PROJECT MODE:.*?(?:\n-{3,}\s*|$)", re.DOTALL) + + +def strip_project_mode(text: str) -> str: + """剔除文本中由 project_mode 插件追加的 L1 注入块。""" + return _PM_BLOCK_RE.sub("", text or "") + + +def _user_text(prompt_body): + """User-typed text from a prompt JSON; '' if this is an agent auto-continuation. + + A Prompt is auto-continue when *either* (a) it carries any tool_result block + (so it's the next round of an in-flight LLM call), or (b) its text blocks all + match known injection prefixes ([WORKING MEMORY], [SYSTEM TIPS], [System] + regenerate prompts, [DANGER] guards, etc.). Real first-prompts only contain + one plain text block with no injection markers. + """ + try: msg = json.loads(prompt_body) + except Exception: return '' + if not isinstance(msg, dict): return '' + blocks = msg.get('content', []) or [] + if any(isinstance(b, dict) and b.get('type') == 'tool_result' for b in blocks): + return '' + for blk in blocks: + if isinstance(blk, dict) and blk.get('type') == 'text': + t = strip_project_mode(blk.get('text') or '').strip() + if t and not any(mk in t for mk in _INJECT_MARKERS): return t + return '' + + +def _assistant_text(response_body): + """Joined plain text from a response blocks repr; '' on parse failure. + Used by /export to grab the model's prose only, without tool noise. + """ + try: blocks = ast.literal_eval(response_body) + except Exception: return '' + if not isinstance(blocks, list): return '' + return '\n'.join(b['text'] for b in blocks + if isinstance(b, dict) and b.get('type') == 'text' + and isinstance(b.get('text'), str) and b['text'].strip()) + + +def _format_tool_use(block): + """Match agent_loop.py:78 verbose tool-call header byte-for-byte. + + MUST use agent_loop's `get_pretty_json`, not a plain `json.dumps`: the + former rewrites a `script` arg's `"; "` into `";\\n "`, so for tools + carrying `script` (code_run, web_execute_js) a plain dumps produces a + *different* fence body. The TUI's write/read/code cards content-address + their captures by `hash(get_pretty_json(args))`; a mismatched fence here + means the hash misses and the card silently falls back to the raw block.""" + name = block.get('name', '?') + args = block.get('input', {}) + try: + from agent_loop import get_pretty_json + pretty = get_pretty_json(args) + except Exception: + try: pretty = json.dumps(args, indent=2, ensure_ascii=False).replace('\\n', '\n') + except Exception: pretty = str(args) + return f"🛠️ Tool: `{name}` 📥 args:\n````text\n{pretty}\n````\n" + + +def _format_tool_result(content): + """Match agent_loop.py:79-81 five-backtick fence around tool output.""" + if isinstance(content, list): + parts = [] + for b in content: + if isinstance(b, dict) and b.get('type') == 'text': + parts.append(b.get('text', '') or '') + elif isinstance(b, str): + parts.append(b) + body = '\n'.join(parts) + else: + body = '' if content is None else str(content) + return f"`````\n{body}\n`````\n" + + +def _tool_results_from_prompt(prompt_body): + """Return {tool_use_id: formatted_fence} from a Prompt JSON's content blocks.""" + try: msg = json.loads(prompt_body) + except Exception: return {} + if not isinstance(msg, dict): return {} + out = {} + for blk in msg.get('content', []) or []: + if isinstance(blk, dict) and blk.get('type') == 'tool_result': + tid = blk.get('tool_use_id') or '' + if tid: out[tid] = _format_tool_result(blk.get('content')) + return out + + +def _format_response_segment(response_body, tool_results): + """Rebuild one LLM call's transcript slice: text blocks + tool_use headers + + matching tool_result fences. Mirrors agent_loop verbose output so fold_turns + sees the same string shape as live mode. + """ + try: blocks = ast.literal_eval(response_body) + except Exception: return '' + if not isinstance(blocks, list): return '' + texts, tool_parts = [], [] + for b in blocks: + if not isinstance(b, dict): continue + t = b.get('type') + if t == 'text': + s = b.get('text', '') + if isinstance(s, str) and s.strip(): texts.append(s) + elif t == 'tool_use': + tool_parts.append(_format_tool_use(b)) + tid = b.get('id') or '' + if tid and tid in tool_results: tool_parts.append(tool_results[tid]) + return '\n\n'.join(p for p in ['\n\n'.join(texts), '\n'.join(tool_parts)] if p) + + +_PLAN_ENTRY_RE = re.compile(r'enter_plan_mode\(\s*[\'"]([^\'"]+plan\.md)[\'"]') + + +def find_plan_entry(path): + """Last `enter_plan_mode("…plan.md")` call in a model_responses log. + + Plan mode has exactly one entry point (plan_sop.md): a `code_run` tool call + whose inline_eval script invokes `handler.enter_plan_mode(...)`. That call + survives in the log as a structured `tool_use` block — unlike a plan path + merely *mentioned* in chat text, it cannot be produced by the user typing + a filename. Scanning these blocks is therefore the restore criterion for + the plan card; the last match wins so re-entered plans track the newest. + + Returns the plan.md path string as written in the script, or None. + """ + try: + with open(path, encoding='utf-8', errors='replace') as f: + content = f.read() + except Exception: + return None + last = None + for _prompt, response in _pairs(content): + try: + blocks = ast.literal_eval(response) + except Exception: + continue + if not isinstance(blocks, list): + continue + for b in blocks: + if not (isinstance(b, dict) and b.get('type') == 'tool_use' + and b.get('name') == 'code_run'): + continue + m = _PLAN_ENTRY_RE.search(str((b.get('input') or {}).get('script') or '')) + if m: + last = m.group(1) + return last + + +def iter_write_captures(path): + """Replay a log's file_write/file_patch/file_read calls into capture dicts + the TUI can feed to its card renderers (`_WRITE_CAP`), keyed later by + hash(get_pretty_json). + + Live mode fills `_WRITE_CAP` from tool_before/tool_after hooks (with a real + pre-write disk snapshot); on /continue that history is gone, but the + structured `tool_use.input` survives in the log — clean, complete args. We + also track each path's content *within this session* so a file + written/patched several times shows real old→new diffs (not N× full "new + file"). Files first touched by an untracked on-disk state still fall back + to a full-content block. + + Returns write entries `{"name", "args", "existed", "old", "status", "msg"}` + and read entries `{"name", "args", "content"}` in call order. `status`/`msg` + come from the matching tool_result so the header can show ✗ on a failed + write; a read's `content` is the raw tool_result text (the read card strips + its LLM-facing chrome itself). + """ + try: + with open(path, encoding='utf-8', errors='replace') as f: + content = f.read() + except Exception: + return [] + pairs = _pairs(content) + # tool_use_id -> (status, msg) from any prompt's tool_result blocks (the + # result lands in the *next* round's Prompt as a tool_result whose content + # is the json-dumped outcome.data, e.g. {"status":"success","msg":...}). + # tr_raw keeps the undecoded text — a file_read result is plain text. + tr_status, tr_raw = {}, {} + for prompt, _ in pairs: + try: + msg_obj = json.loads(prompt) + except Exception: + continue + if not isinstance(msg_obj, dict): + continue + for blk in msg_obj.get('content', []) or []: + if not (isinstance(blk, dict) and blk.get('type') == 'tool_result'): + continue + tid = blk.get('tool_use_id') + c = blk.get('content') + if isinstance(c, list): + c = ''.join(b.get('text', '') for b in c + if isinstance(b, dict) and b.get('type') == 'text') + if tid and isinstance(c, str): + tr_raw[tid] = c + try: + d = json.loads(c) if isinstance(c, str) else None + except Exception: + d = None + if tid and isinstance(d, dict): + tr_status[tid] = (d.get('status'), str(d.get('msg') or '')) + + out, state = [], {} + for _prompt, response in pairs: + try: + blocks = ast.literal_eval(response) + except Exception: + continue + if not isinstance(blocks, list): + continue + for b in blocks: + if not (isinstance(b, dict) and b.get('type') == 'tool_use'): + continue + name = b.get('name') + if name not in ('file_write', 'file_patch', 'file_read', 'code_run'): + continue + args = b.get('input') or {} + p = args.get('path') + if name == 'file_read': + out.append({'name': name, 'args': args, + 'content': tr_raw.get(b.get('id'))}) + continue + if name == 'code_run': + # data = the tool_result text; a dict result is JSON, an + # inline_eval / code-missing result is plain text. Pass the + # parsed dict when possible so the card reads exit_code/stdout; + # else the raw string (the card handles both). + raw = tr_raw.get(b.get('id')) + d = raw + try: + parsed = json.loads(raw) if isinstance(raw, str) else None + if isinstance(parsed, dict): + d = parsed + except Exception: + pass + out.append({'name': name, 'args': args, 'data': d}) + continue + st, mg = tr_status.get(b.get('id'), (None, '')) + if name == 'file_patch': + # If this file's content is tracked within the session, pass it as + # the pre-write full file so the renderer can do a whole-file diff + # (real line numbers + context); else fall back to the fragment. + pre = state.get(p, '') + out.append({'name': name, 'args': args, + 'existed': p in state, 'old': pre, + 'status': st, 'msg': mg}) + if st == 'error': + continue # failed call left the disk untouched — don't book it + old = args.get('old_content') or '' + if p in state and old: + state[p] = state[p].replace(old, args.get('new_content') or '', 1) + else: # file_write + existed = p in state + old = state.get(p, '') + new = str(args.get('content') or '') + mode = str(args.get('mode') or 'overwrite') + out.append({'name': name, 'args': args, 'existed': existed, 'old': old, + 'status': st, 'msg': mg}) + if st == 'error': + continue # failed call left the disk untouched — don't book it + if mode == 'append': + state[p] = old + new + elif mode == 'prepend': + state[p] = new + old + else: + state[p] = new + return out + + +def extract_ui_messages(path): + """Parse a model_responses log into [{role, content}, ...] for UI replay. + + Each user-initiated round becomes one user bubble plus one assistant bubble. + Auto-continuation LLM calls are concatenated into the same assistant bubble, + separated by ``**LLM Running (Turn N) ...**`` markers. Tool calls and their + results are rendered into the assistant content using the same string format + that agent_loop yields live, so fold_turns can fold them identically. + """ + try: + with open(path, encoding='utf-8', errors='replace') as f: content = f.read() + except Exception: return [] + pairs = _pairs(content) + if not pairs: return [] + # tool_results live in the *next* Prompt's content; index look-ahead. + next_tr = [{} for _ in pairs] + for i in range(len(pairs) - 1): + next_tr[i] = _tool_results_from_prompt(pairs[i + 1][0]) + + out, assistant, round_turn = [], None, 0 + for i, (prompt, response) in enumerate(pairs): + user = _user_text(prompt) + seg = _format_response_segment(response, next_tr[i]) + if user: + if assistant is not None: out.append(assistant) + out.append({'role': 'user', 'content': user}) + # Turn 1 marker too — agent_loop yields one per LLM call, including the + # first, so fold_turns treats every non-last call uniformly as a fold. + assistant = {'role': 'assistant', + 'content': f"\n\n**LLM Running (Turn 1) ...**\n\n{seg}"} + round_turn = 1 + else: + if assistant is None: + assistant = {'role': 'assistant', 'content': ''} + round_turn = 1 + round_turn += 1 + marker = f"\n\n**LLM Running (Turn {round_turn}) ...**\n\n" + assistant['content'] = (assistant['content'] or '') + marker + seg + if assistant is not None: out.append(assistant) + return [m for m in out if (m.get('content') or '').strip()] + + +def handle_frontend_command(agent, query, exclude_pid=None): + """Frontend-friendly /continue entry that returns text directly.""" + s = (query or '').strip() + exclude_pid = os.getpid() if exclude_pid is None else exclude_pid + if s == '/continue': + return format_list(list_sessions(exclude_pid=exclude_pid)) + m = re.match(r'/continue\s+(\d+)\s*$', s) + if not m: + return '用法: /continue 或 /continue N' + sessions = list_sessions(exclude_pid=exclude_pid) + idx = int(m.group(1)) - 1 + if not (0 <= idx < len(sessions)): + return f'❌ 索引越界(有效范围 1-{len(sessions)})' + reset_conversation(agent, message=None) + msg, _ = restore(agent, sessions[idx][0]) + return msg + + +# =========================================================================== +# 原地复原(in-place continue)共享层 —— 仅供 TUI(tui_v2/tui_v3 及其 rewind 副本) +# 调用;其它前端(IM/qt/streamlit…)不调用这些函数,行为完全不受影响。 +# +# 模型:每个会话 = 一个 `model_responses_.txt`,身份就是文件本身。 +# · 原地续 X = 把 agent 的 log_path 指回 X,之后的轮次追加到 X 本身(同一会话延续)。 +# · 拷贝续 X = 铸一个新 logid、把 X 拷进去,在副本上续;X 原件不动(并发安全)。 +# · 切走/新对话 = 释放当前锁、旧日志原样留作"空闲会话"(不存快照、不清空),新对话铸新 logid。 +# +# 独占:每个 TUI 会话出生即持有自己日志的一把锁(`.locks/.lock`); +# 整进程共用一个心跳线程,每 ~5s touch 锁文件 mtime(无 fsync)。 +# 判活 = 锁 mtime 在 30s 内新鲜;超 30s 视为持锁者已死,可被接管。 +# 抢锁用原子 O_EXCL;锁基础设施任何故障都降级为"假定空闲、放行续接",绝不阻断 /continue。 +# =========================================================================== + +_LOCK_DIR = os.path.join(_LOG_DIR, '.locks') +_HB_INTERVAL = 5.0 # 心跳间隔(秒) +_STALE_AFTER = 30.0 # 超过这么久无心跳 → 持锁者视为已死,可接管 +_held_locks = set() # 本进程当前持有的 log_path 集合 +_hb_lock = threading.Lock() +_hb_thread = None + + +def _lock_path(log_path): + base = os.path.splitext(os.path.basename(log_path))[0] + return os.path.join(_LOCK_DIR, base + '.lock') + + +def _read_lock(lock_file): + try: + with open(lock_file, encoding='utf-8') as fh: + return json.load(fh) + except Exception: + return None + + +def _lock_fresh(lock_file): + """心跳新鲜度 = 锁文件 mtime 距今 < _STALE_AFTER。""" + try: + return (time.time() - os.path.getmtime(lock_file)) < _STALE_AFTER + except OSError: + return False + + +def session_occupant(log_path): + """若 `log_path` 正被一个活着的(心跳新鲜)进程持有,返回其 owner 元数据 dict; + 否则返回 None(空闲,或锁已过期可接管)。供 TUI 判断"原地 / 弹窗拷贝"。""" + lf = _lock_path(log_path) + meta = _read_lock(lf) + if meta is not None and _lock_fresh(lf): + return meta + return None + + +def acquire_lock(log_path, agent_id=None): + """尝试独占 `log_path`。成功(或锁设施故障降级)返回 True; + 仅当被另一活进程(心跳新鲜)持有时返回 False。""" + try: + os.makedirs(_LOCK_DIR, exist_ok=True) + lf = _lock_path(log_path) + meta = {'pid': os.getpid(), 'agent_id': agent_id, + 'log': os.path.basename(log_path), + 'started': time.time()} + blob = json.dumps(meta, ensure_ascii=False) + try: + fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, 'w', encoding='utf-8') as fh: + fh.write(blob) + except FileExistsError: + cur = _read_lock(lf) + if cur and cur.get('pid') != os.getpid() and _lock_fresh(lf): + return False # 被另一活进程持有 + # 过期锁 / 本进程自己的 → 接管(覆盖)。小竞态窗口可接受。 + try: os.remove(lf) + except OSError: pass + try: + fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, 'w', encoding='utf-8') as fh: + fh.write(blob) + except FileExistsError: + cur2 = _read_lock(lf) + if cur2 and cur2.get('pid') != os.getpid() and _lock_fresh(lf): + return False # 抢锁竞态输了 + with open(lf, 'w', encoding='utf-8') as fh: + fh.write(blob) + with _hb_lock: + _held_locks.add(log_path) + _ensure_hb_thread() + return True + except Exception: + # 锁设施故障绝不阻断续接 —— 降级为"假定空闲、放行"。 + return True + + +def release_lock(log_path): + """释放本进程对 `log_path` 的锁(只删自己持有/无主的锁文件)。""" + with _hb_lock: + _held_locks.discard(log_path) + try: + lf = _lock_path(log_path) + cur = _read_lock(lf) + if cur is None or cur.get('pid') == os.getpid(): + os.remove(lf) + except Exception: + pass + + +def _hb_tick(): + now = time.time() + with _hb_lock: + items = list(_held_locks) + for lp in items: + try: + os.utime(_lock_path(lp), (now, now)) # 仅更新 mtime,无 fsync + except OSError: + pass + + +def _hb_loop(): + while True: + time.sleep(_HB_INTERVAL) + try: + _hb_tick() + except Exception: + pass + + +def _ensure_hb_thread(): + global _hb_thread + with _hb_lock: + if _hb_thread is None: + _hb_thread = threading.Thread(target=_hb_loop, + name='ga-session-heartbeat', daemon=True) + _hb_thread.start() + + +@atexit.register +def _release_all_locks(): + for lp in list(_held_locks): + release_lock(lp) + + +def _new_log_path(): + """铸一个新的 6 位 logid 日志路径(与 agentmain 同公式)。""" + logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}' + return os.path.join(_LOG_DIR, f'model_responses_{logid}.txt') + + +def _retarget_log(agent, new_path): + """把 agent(及其所有 llmclient)的日志写入点切到 new_path —— 之后的轮次写这里。""" + try: + agent.log_path = new_path + except Exception: + pass + for client in _agent_clients(agent): + try: client.log_path = new_path + except Exception: pass + + +def is_snapshot(path): + """遗留快照存档(model_responses_snapshot_*.txt)。这类只能拷贝续,不参与原地 + (provisional,待 worktree 复审)。""" + return os.path.basename(path).startswith('model_responses_snapshot_') + + +def _clear_conversation_state(agent): + """清空对话状态(对齐 reset_conversation,但不碰日志文件)。""" + if hasattr(agent, 'history'): + agent.history = [] + for client in _agent_clients(agent): + backend = getattr(client, 'backend', None) + if backend is not None and hasattr(backend, 'history'): + backend.history = [] + if hasattr(client, 'last_tools'): + client.last_tools = '' + if hasattr(agent, 'handler'): + agent.handler = None + + +def acquire_birth_lock(agent, agent_id=None): + """会话出生时持有自己当前日志的锁(新 logid 必然抢到)。TUI 在建会话时调用, + 使本会话对"占用检测"可见 —— 别的会话才能据此判定它是否还活着。""" + lp = getattr(agent, 'log_path', '') or '' + if lp: + acquire_lock(lp, agent_id) + + +def release_current(agent): + """切走:释放 agent 当前日志的锁,旧日志原样留作"空闲会话"(不存快照、不清空)。""" + lp = getattr(agent, 'log_path', '') or '' + if lp: + release_lock(lp) + + +def begin_fresh_session(agent, agent_id=None): + """新对话 / clear:释放当前锁(旧日志留作空闲会话)→ 铸新 logid 重指 → 持新锁 → + 清空对话状态。**替代 TUI 里的 reset_conversation**(不再存快照/清空旧日志)。""" + try: agent.abort() + except Exception: pass + release_current(agent) + newp = _new_log_path() + _retarget_log(agent, newp) + acquire_lock(newp, agent_id) + _clear_conversation_state(agent) + + +def _load_history_into(agent, path, restore_wm=False): + """把 `path` 解析进 backend.history(native;否则降级摘要)。镜像 restore() 的解析, + 但不 abort/不快照(日志重指由调用方先做好)。返回 (msg, is_full)。 + + `restore_wm`(默认 False,仅显式传入的调用方启用,如 worldline TUI):从同一份日志 + 派生 history_info 写回 `agent.history`,使续接后工作记忆不丢。默认关闭 → 其它前端 + 行为完全不变。""" + try: + with open(path, encoding='utf-8', errors='replace') as fh: + content = fh.read() + except Exception as e: + return f'❌ 读取失败: {e}', False + pairs = _pairs(content) + if not pairs: + return f'❌ {os.path.basename(path)} 为空或格式不符', False + history = _parse_native_history(pairs) + name = os.path.basename(path) + if history is not None: + _replace_backend_history(agent, history) + if restore_wm and hasattr(agent, 'history'): + agent.history = _derive_hist_info(history) # 续接恢复工作记忆(opt-in) + return f'✅ 已恢复 {len(pairs)} 轮完整对话({name})', True + from chatapp_common import _restore_native_history, _restore_text_pairs + summary = _restore_text_pairs(content) or _restore_native_history(content) + if not summary: + return f'❌ {name} 无法解析(非 native 且无摘要可提取)', False + if hasattr(agent, 'history'): + agent.history.extend(summary) + n = sum(1 for l in summary if l.startswith('[USER]: ')) + return f'⚠️ 非 native 格式,降级恢复 {n} 轮摘要({name})', False + + +def _is_empty_log(path): + """日志空(<32 字节)或缺失。用于 allow_empty:回退到会话起点后日志被清空的会话。""" + try: + return os.path.getsize(path) < 32 + except OSError: + return True + + +def continue_inplace(agent, path, agent_id=None, allow_empty=False, restore_wm=False): + """原地续:把 agent 的日志指回 `path` 本身,之后轮次追加到 X,延续同一会话。 + 调用方应已确认空闲(session_occupant 为 None);抢锁失败(被占)返回错误。 + `allow_empty`(仅 worldline UI 传):日志为空时不报错,按【空会话】恢复(清空对话, + 由调用方按 `.ga_rewind` 树重连),用于"回退至会话起点"的会话。 + `restore_wm`(opt-in):续接后从日志派生 history_info 恢复工作记忆。返回 (msg, ok)。""" + try: agent.abort() + except Exception: pass + if not acquire_lock(path, agent_id): # 先抢到目标锁;失败则保持现状,不丢自己的锁 + return '❌ 会话已被占用,无法原地接管', False + cur = getattr(agent, 'log_path', '') or '' + if cur and os.path.basename(cur) != os.path.basename(path): + release_lock(cur) # 目标到手,旧会话释放为空闲(同一文件则不放) + _retarget_log(agent, path) + msg, ok = _load_history_into(agent, path, restore_wm=restore_wm) + if not ok and allow_empty and _is_empty_log(path): + _replace_backend_history(agent, []) # 空会话:清空对话(载入失败时它没被清) + return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True + return msg, ok + + +def continue_copy(agent, path, agent_id=None, allow_empty=False, restore_wm=False): + """拷贝续:铸新 logid、把 `path` 内容拷进去,在副本上续;`path` 原件不动。 + 用于"被占用→用户选拷贝"以及快照源。`restore_wm`(opt-in)同 continue_inplace。 + 返回 (msg, ok)。""" + try: agent.abort() + except Exception: pass + release_current(agent) + newp = _new_log_path() + try: + shutil.copyfile(path, newp) + except Exception: + pass + acquire_lock(newp, agent_id) + _retarget_log(agent, newp) + msg, ok = _load_history_into(agent, newp, restore_wm=restore_wm) + if not ok and allow_empty and _is_empty_log(newp): + _replace_backend_history(agent, []) + return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True + return msg, ok + + +def install(cls): + """Wrap cls._handle_slash_cmd so /continue is handled before original dispatch.""" + orig = cls._handle_slash_cmd + if getattr(orig, '_continue_patched', False): return + def patched(self, raw_query, display_queue): + if (raw_query or '').startswith('/continue'): + r = handle(self, raw_query, display_queue) + if r is None: return None + return orig(self, raw_query, display_queue) + patched._continue_patched = True + cls._handle_slash_cmd = patched diff --git a/frontends/cost_tracker.py b/frontends/cost_tracker.py new file mode 100644 index 0000000..7442589 --- /dev/null +++ b/frontends/cost_tracker.py @@ -0,0 +1,175 @@ +"""Per-thread LLM token usage via llmcore monkey-patches. + +`install()` wraps `llmcore._record_usage` + `llmcore.print` (the SSE +`messages` path only emits final `output_tokens` through `[Output] tokens=N`). +Trackers are keyed by `threading.current_thread().name`; each TUI session +runs its agent on `ga-tui-agent-`, so `/cost` is a thread lookup. + +Subagent processes are out-of-process, so `scan_subagent_logs` parses the +same `[Cache]` / `[Output]` print lines from `temp/*/stdout.log`. +""" +from __future__ import annotations +import glob, os, re, threading, time +from dataclasses import dataclass, field + + +@dataclass +class TokenStats: + requests: int = 0 + input: int = 0 + output: int = 0 + cache_create: int = 0 + cache_read: int = 0 + # Latest single-LLM-call sizes — drive the spinner's `↑ N · ↓ M`. + last_input: int = 0 + last_output: int = 0 + started_at: float = field(default_factory=time.time) + + def total_input_side(self) -> int: + return self.input + self.cache_create + self.cache_read + + def total_tokens(self) -> int: + return self.input + self.output + self.cache_create + self.cache_read + + def cache_hit_rate(self) -> float: + side = self.total_input_side() + return (self.cache_read / side * 100.0) if side else 0.0 + + def elapsed_seconds(self) -> float: + return max(0.0, time.time() - self.started_at) + + +# GA's real context budget lives on `BaseSession.context_win` (chars). The +# trim trigger is `context_win * 3` (see llmcore.trim_messages_history), so +# `/cost` compares actual-history chars against that cap for consistent units. +def context_window_chars(backend) -> int: + """`context_win * 3` — the char cap before `trim_messages_history` kicks + in. Reads dynamically so a `mykey.py` override propagates. Returns 0 on + bad/missing backend so the caller can hide the row.""" + try: + return int(getattr(backend, 'context_win', 0)) * 3 + except (TypeError, ValueError): + return 0 + + +def current_input_chars(backend) -> int: + """Char-size of the message history (same unit as `trim_messages_history`).""" + try: + import json as _json + history = getattr(backend, 'history', None) or [] + return sum(len(_json.dumps(m, ensure_ascii=False)) for m in history) + except Exception: + return 0 + + +_trackers: dict[str, TokenStats] = {} +_lock = threading.Lock() +_OUT_RE = re.compile(r'\[Output\]\s+tokens=(\d+)') +_CACHE_RE_NEW = re.compile(r'\[Cache\]\s+input=(\d+)\s+creation=(\d+)\s+read=(\d+)') +_CACHE_RE_OLD = re.compile(r'\[Cache\]\s+input=(\d+)\s+cached=(\d+)') +_INSTALLED = False +_SUBAGENT_GLOB = os.path.join("temp", "*", "stdout.log") + + +def scan_subagent_logs(since: float = 0.0, root: str | None = None) -> TokenStats: + """Aggregate subagent tokens from `temp//stdout.log` files; pass + `since=tui_start_time` to scope to this run. Best-effort: bad logs skipped.""" + out = TokenStats() + if since > 0: out.started_at = since + pattern = os.path.join(root, _SUBAGENT_GLOB) if root else _SUBAGENT_GLOB + for p in glob.glob(pattern): + try: + if since and os.path.getmtime(p) < since: continue + with open(p, encoding="utf-8", errors="ignore") as f: + for line in f: + if line.startswith("[Output]"): + m = _OUT_RE.match(line) + if m: + out.output += int(m.group(1)); out.requests += 1 + elif line.startswith("[Cache]"): + # messages → `input=N creation=C read=R` (input excl. cache); + # chat_completions / responses → `input=N cached=R` (input incl. cached). + m = _CACHE_RE_NEW.match(line) + if m: + i, c, r = int(m.group(1)), int(m.group(2)), int(m.group(3)) + out.input += i + out.cache_create += c; out.cache_read += r + continue + m = _CACHE_RE_OLD.match(line) + if m: + i, r = int(m.group(1)), int(m.group(2)) + out.input += max(0, i - r); out.cache_read += r + except OSError: + continue + return out + + +def get(thread_name: str) -> TokenStats: + with _lock: + if thread_name not in _trackers: + _trackers[thread_name] = TokenStats() + return _trackers[thread_name] + + +def reset(thread_name: str) -> None: + with _lock: + _trackers.pop(thread_name, None) + + +def all_trackers() -> dict[str, TokenStats]: + with _lock: + return dict(_trackers) + + +def install() -> None: + """Idempotently wrap llmcore._record_usage and llmcore.print.""" + global _INSTALLED + if _INSTALLED: return + import llmcore + orig_record, orig_print = llmcore._record_usage, print + + def record_patched(usage, api_mode): + # Handles INPUT / CACHE only; OUTPUT comes via `[Output]` print_patched + # below (the SSE path emits it that way; double-counting was the prior bug). + try: + if usage: + t = get(threading.current_thread().name) + t.requests += 1 + if api_mode == 'messages': + inp = int(usage.get('input_tokens', 0) or 0) + cc = int(usage.get('cache_creation_input_tokens', 0) or 0) + cr = int(usage.get('cache_read_input_tokens', 0) or 0) + t.input += inp; t.cache_create += cc; t.cache_read += cr + # Non-stream `messages` skips the [Output] print, so count + # output_tokens here; SSE message_start carries a 1-token + # placeholder to skip. + out = int(usage.get('output_tokens', 0) or 0) + if out > 1: t.output += out; t.last_output = out + t.last_input = inp + cc + cr + elif api_mode == 'chat_completions': + cached = int((usage.get('prompt_tokens_details') or {}).get('cached_tokens', 0) or 0) + inp = int(usage.get('prompt_tokens', 0) or 0) - cached + t.input += inp; t.cache_read += cached + t.last_input = inp + cached + elif api_mode == 'responses': + cached = int((usage.get('input_tokens_details') or {}).get('cached_tokens', 0) or 0) + inp = int(usage.get('input_tokens', 0) or 0) - cached + t.input += inp; t.cache_read += cached + t.last_input = inp + cached + except Exception: pass + return orig_record(usage, api_mode) + llmcore._record_usage = record_patched + + def print_patched(*args, **kwargs): + try: + if args and isinstance(args[0], str): + m = _OUT_RE.match(args[0]) + if m: + t = get(threading.current_thread().name) + n = int(m.group(1)) + t.output += n; t.last_output = n + except Exception: pass + return orig_print(*args, **kwargs) + llmcore.print = print_patched + + _INSTALLED = True diff --git a/frontends/dcapp.py b/frontends/dcapp.py new file mode 100644 index 0000000..481196d --- /dev/null +++ b/frontends/dcapp.py @@ -0,0 +1,407 @@ +# Discord Bot Frontend for GenericAgent +# ⚠️ 需要在 Discord Developer Portal 开启 "Message Content Intent" +# Bot → Privileged Gateway Intents → MESSAGE CONTENT INTENT → 打开 +# pip install discord.py + +import asyncio, json, os, queue as Q, re, sys, threading, time +from collections import OrderedDict + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from agentmain import GeneraticAgent +from chatapp_common import ( + AgentChatMixin, build_done_text, ensure_single_instance, extract_files, + public_access, redirect_log, require_runtime, split_text, strip_files, clean_reply, + HELP_TEXT, FILE_HINT, format_restore, + _handle_continue_frontend, _reset_conversation, +) +from llmcore import mykeys + +try: + import discord +except Exception: + print("Please install discord.py to use Discord: pip install discord.py") + sys.exit(1) + +agent = GeneraticAgent(); agent.verbose = False +BOT_TOKEN = str(mykeys.get("discord_bot_token", "") or "").strip() +ALLOWED = {str(x).strip() for x in mykeys.get("discord_allowed_users", []) if str(x).strip()} +USER_TASKS = {} +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEMP_DIR = os.path.join(PROJECT_ROOT, "temp") +MEDIA_DIR = os.path.join(TEMP_DIR, "discord_media") +ACTIVE_FILE = os.path.join(TEMP_DIR, "discord_active_channels.json") +ACTIVE_TTL_SECONDS = 30 * 24 * 3600 +EXIT_CHANNEL_TEXTS = {"退出该频道", "退出此频道", "退出频道"} +EXIT_THREAD_TEXTS = {"退出该子区", "退出此子区", "退出子区"} +os.makedirs(MEDIA_DIR, exist_ok=True) + + +def _extract_discord_progress(text): + """Return the newest concise from a streaming transcript.""" + matches = re.findall(r"\s*(.*?)\s*", text or "", flags=re.DOTALL) + if not matches: + return "" + summary = re.sub(r"\s+", " ", matches[-1]).strip() + return summary[:120] + + +def _strip_discord_transcript(text): + """Hide LLM/tool transcript noise while preserving the final natural reply.""" + text = text or "" + text = re.sub(r"^\s*\*?\*?LLM Running \(Turn \d+\) \.\.\.\*?\*?\s*$", "", text, flags=re.M) + text = re.sub(r"^\s*🛠️\s+.*?(?=^\s*(?:\*?\*?LLM Running||$))", "", text, flags=re.M | re.DOTALL) + text = re.sub(r"^\s*(?:✅|❌|ERR|STDOUT|PAT\b|RC\b).*?$", "", text, flags=re.M) + text = re.sub(r".*?", "", text, flags=re.DOTALL) + text = clean_reply(text) + return strip_files(text).strip() + + +def _display_done_text(text): + body = _strip_discord_transcript(text) + if body and body != "...": + return body + summaries = re.findall(r"\s*(.*?)\s*", text or "", flags=re.DOTALL) + if summaries: + return re.sub(r"\s+", " ", summaries[-1]).strip() or "..." + return "..." + + +class DiscordApp(AgentChatMixin): + label, source, split_limit = "Discord", "discord", 1900 + + def __init__(self): + super().__init__(agent, USER_TASKS) + intents = discord.Intents.default() + intents.message_content = True + intents.guilds = True + intents.dm_messages = True + proxy = str(mykeys.get("proxy", "") or "").strip() or None + self.client = discord.Client(intents=intents, proxy=proxy) + self.background_tasks = set() + self._channel_cache = OrderedDict() # chat_id -> channel/user object (LRU, max 500) + self._active_channels = self._load_active_channels() # guild chat_id -> {last_seen: float} + self._active_lock = threading.Lock() + self._agents = OrderedDict() # chat_id -> GeneraticAgent, each chat has isolated history + self._agent_lock = threading.Lock() + + @self.client.event + async def on_ready(): + print(f"[Discord] bot ready: {self.client.user} ({self.client.user.id})") + + @self.client.event + async def on_message(message): + await self._handle_message(message) + + def _chat_id(self, message): + """Return a string chat_id: 'dm:' or 'ch:'.""" + if isinstance(message.channel, discord.DMChannel): + return f"dm:{message.author.id}" + return f"ch:{message.channel.id}" + + def _load_active_channels(self): + try: + with open(ACTIVE_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + return {} + now = time.time() + active = {} + for chat_id, item in data.items(): + if not str(chat_id).startswith("ch:") or not isinstance(item, dict): + continue + last_seen = float(item.get("last_seen") or 0) + if now - last_seen <= ACTIVE_TTL_SECONDS: + active[str(chat_id)] = {"last_seen": last_seen} + return active + except FileNotFoundError: + return {} + except Exception as e: + print(f"[Discord] failed to load active channels: {e}") + return {} + + def _save_active_channels(self): + try: + os.makedirs(os.path.dirname(ACTIVE_FILE), exist_ok=True) + tmp = ACTIVE_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(self._active_channels, f, ensure_ascii=False, indent=2, sort_keys=True) + os.replace(tmp, ACTIVE_FILE) + except Exception as e: + print(f"[Discord] failed to save active channels: {e}") + + def _is_active_channel(self, chat_id, now=None): + now = now or time.time() + with self._active_lock: + item = self._active_channels.get(chat_id) + if not item: + return False + if now - float(item.get("last_seen") or 0) > ACTIVE_TTL_SECONDS: + self._active_channels.pop(chat_id, None) + self._save_active_channels() + print(f"[Discord] channel expired: {chat_id}") + return False + return True + + def _touch_active_channel(self, chat_id, now=None): + if not chat_id.startswith("ch:"): + return + with self._active_lock: + self._active_channels[chat_id] = {"last_seen": float(now or time.time())} + self._save_active_channels() + + def _deactivate_channel(self, chat_id): + with self._active_lock: + changed = self._active_channels.pop(chat_id, None) is not None + self._save_active_channels() + state = self.user_tasks.get(chat_id) + if state: + state["running"] = False + try: + self._get_agent(chat_id).abort() + except Exception as e: + print(f"[Discord] deactivate abort failed for {chat_id}: {e}") + return changed + + def _get_agent(self, chat_id): + with self._agent_lock: + ga = self._agents.get(chat_id) + if ga is None: + ga = GeneraticAgent() + ga.verbose = False + self._agents[chat_id] = ga + threading.Thread(target=ga.run, daemon=True, name=f"discord-agent-{chat_id}").start() + if len(self._agents) > 200: + old_chat_id, _old_agent = self._agents.popitem(last=False) + print(f"[Discord] dropped agent cache entry: {old_chat_id}") + else: + self._agents.move_to_end(chat_id) + return ga + + async def _download_attachments(self, message): + """Download attachments/images to MEDIA_DIR, return list of local paths.""" + paths = [] + for att in message.attachments: + safe_name = re.sub(r'[<>:"/\\|?*]', '_', att.filename or f"file_{att.id}") + local_path = os.path.join(MEDIA_DIR, f"{att.id}_{safe_name}") + try: + await att.save(local_path) + paths.append(local_path) + print(f"[Discord] saved attachment: {local_path}") + except Exception as e: + print(f"[Discord] failed to save attachment {att.filename}: {e}") + return paths + + async def send_text(self, chat_id, content, **ctx): + """Send text (and optionally files) to a chat_id.""" + channel = self._channel_cache.get(chat_id) + if channel is None: + try: + if chat_id.startswith("dm:"): + user = await self.client.fetch_user(int(chat_id[3:])) + channel = await user.create_dm() + else: + channel = await self.client.fetch_channel(int(chat_id[3:])) + self._channel_cache[chat_id] = channel + if len(self._channel_cache) > 500: + self._channel_cache.popitem(last=False) + except Exception as e: + print(f"[Discord] cannot resolve channel for {chat_id}: {e}") + return + for part in split_text(content, self.split_limit): + try: + await channel.send(part) + except Exception as e: + print(f"[Discord] send error: {e}") + + async def send_done(self, chat_id, raw_text, **ctx): + """Send final reply: text parts + file attachments.""" + files = [p for p in extract_files(raw_text) if os.path.exists(p)] + body = _display_done_text(raw_text) + + # Send text (send_text handles splitting internally) + if body and body != "...": + await self.send_text(chat_id, body, **ctx) + + # Send files as Discord attachments + if files: + channel = self._channel_cache.get(chat_id) + if channel: + for fpath in files: + try: + await channel.send(file=discord.File(fpath)) + except Exception as e: + print(f"[Discord] failed to send file {fpath}: {e}") + await self.send_text(chat_id, f"⚠️ 文件发送失败: {os.path.basename(fpath)}", **ctx) + + if not body and not files: + await self.send_text(chat_id, "...", **ctx) + + async def handle_command(self, chat_id, cmd, **ctx): + """Handle slash commands against the per-chat agent, keeping Discord chats isolated.""" + ga = self._get_agent(chat_id) + parts = (cmd or "").split() + op = (parts[0] if parts else "").lower() + if op == "/help": + return await self.send_text(chat_id, HELP_TEXT, **ctx) + if op == "/stop": + state = self.user_tasks.get(chat_id) + if state: + state["running"] = False + ga.abort() + return await self.send_text(chat_id, "⏹️ 正在停止...", **ctx) + if op == "/status": + llm = ga.get_llm_name() if ga.llmclient else "未配置" + return await self.send_text(chat_id, f"状态: {'🔴 运行中' if ga.is_running else '🟢 空闲'}\nLLM: [{ga.llm_no}] {llm}", **ctx) + if op == "/llm": + if not ga.llmclient: + return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", **ctx) + if len(parts) > 1: + try: + ga.next_llm(int(parts[1])) + return await self.send_text(chat_id, f"✅ 已切换到 [{ga.llm_no}] {ga.get_llm_name()}", **ctx) + except Exception: + return await self.send_text(chat_id, f"用法: /llm <0-{len(ga.list_llms()) - 1}>", **ctx) + lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in ga.list_llms()] + return await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), **ctx) + if op == "/restore": + try: + restored_info, err = format_restore() + if err: + return await self.send_text(chat_id, err, **ctx) + restored, fname, count = restored_info + ga.abort() + ga.history.extend(restored) + return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx) + except Exception as e: + return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx) + if op == "/continue": + return await self.send_text(chat_id, _handle_continue_frontend(ga, cmd), **ctx) + if op == "/new": + return await self.send_text(chat_id, _reset_conversation(ga), **ctx) + return await self.send_text(chat_id, HELP_TEXT, **ctx) + + async def run_agent(self, chat_id, text, **ctx): + """Run the isolated per-chat Discord agent.""" + ga = self._get_agent(chat_id) + state = {"running": True} + self.user_tasks[chat_id] = state + try: + await self.send_text(chat_id, "思考中...", **ctx) + dq = ga.put_task(f"{FILE_HINT}\n\n{text}", source=self.source) + last_ping = time.time() + last_step = "" + step_no = 0 + while state["running"]: + try: + item = await asyncio.to_thread(dq.get, True, 3) + except Q.Empty: + if ga.is_running and time.time() - last_ping > self.ping_interval: + await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", **ctx) + last_ping = time.time() + continue + if "next" in item: + step = _extract_discord_progress(item.get("next", "")) + if step and step != last_step: + step_no += 1 + await self.send_text(chat_id, f"步骤{step_no}:{step}", **ctx) + last_step = step + last_ping = time.time() + continue + if "done" in item: + await self.send_done(chat_id, item.get("done", ""), **ctx) + break + if not state["running"]: + await self.send_text(chat_id, "⏹️ 已停止", **ctx) + except Exception as e: + import traceback + print(f"[{self.label}] run_agent error: {e}") + traceback.print_exc() + await self.send_text(chat_id, f"❌ 错误: {e}", **ctx) + finally: + self.user_tasks.pop(chat_id, None) + + async def _handle_message(self, message): + # Ignore self + if message.author == self.client.user or message.author.bot: + return + + is_dm = isinstance(message.channel, discord.DMChannel) + is_guild = message.guild is not None + chat_id = self._chat_id(message) + now = time.time() + mentioned = bool(is_guild and self.client.user and self.client.user.mentioned_in(message)) + + self._channel_cache[chat_id] = message.channel + if len(self._channel_cache) > 500: + self._channel_cache.popitem(last=False) + + user_id = str(message.author.id) + user_name = str(message.author) + + if not public_access(ALLOWED) and user_id not in ALLOWED: + print(f"[Discord] unauthorized user: {user_name} ({user_id})") + return + + if is_guild: + active = self._is_active_channel(chat_id, now) + if not mentioned and not active: + return + if mentioned or active: + self._touch_active_channel(chat_id, now) + + # Strip bot mention from content + content = message.content or "" + if is_guild and self.client.user: + content = re.sub(rf"<@!?{self.client.user.id}>", "", content).strip() + else: + content = content.strip() + + normalized = re.sub(r"\s+", "", content) + if is_guild and normalized in EXIT_CHANNEL_TEXTS | EXIT_THREAD_TEXTS: + self._deactivate_channel(chat_id) + label = "子区" if normalized in EXIT_THREAD_TEXTS else "频道" + await self.send_text(chat_id, f"✅ 已退出该{label},之后除非重新 @ 我,否则不会主动响应。") + print(f"[Discord] manually deactivated {chat_id} by {user_name} ({user_id})") + return + + # Download attachments + attachment_paths = await self._download_attachments(message) + + # Build message text with attachment paths + if attachment_paths: + paths_text = "\n".join(f"[附件: {p}]" for p in attachment_paths) + content = f"{content}\n{paths_text}" if content else paths_text + + if not content: + return + + print(f"[Discord] message from {user_name} ({user_id}, {'dm' if is_dm else 'guild'}): {content[:200]}") + + if content.startswith("/"): + return await self.handle_command(chat_id, content) + + task = asyncio.create_task(self.run_agent(chat_id, content)) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + + async def start(self): + print("[Discord] bot starting...") + delay, max_delay = 5, 300 + while True: + started_at = time.monotonic() + try: + await self.client.start(BOT_TOKEN) + except Exception as e: + print(f"[Discord] error: {e}") + if time.monotonic() - started_at >= 60: + delay = 5 + print(f"[Discord] reconnect in {delay}s...") + await asyncio.sleep(delay) + delay = min(delay * 2, max_delay) + + +if __name__ == "__main__": + _LOCK_SOCK = ensure_single_instance(19532, "Discord") + require_runtime(agent, "Discord", discord_bot_token=BOT_TOKEN) + redirect_log(__file__, "dcapp.log", "Discord", ALLOWED) + asyncio.run(DiscordApp().start()) diff --git a/frontends/desktop/.gitignore b/frontends/desktop/.gitignore new file mode 100644 index 0000000..b051b24 --- /dev/null +++ b/frontends/desktop/.gitignore @@ -0,0 +1,7 @@ +# Rust build artifacts +src-tauri/target/ +src-tauri/gen/ + +# Node +node_modules/ +package-lock.json diff --git a/frontends/desktop/package.json b/frontends/desktop/package.json new file mode 100644 index 0000000..24e549d --- /dev/null +++ b/frontends/desktop/package.json @@ -0,0 +1,10 @@ +{ + "name": "genericagent-web2", + "version": "0.1.0", + "scripts": { + "tauri": "tauri" + }, + "devDependencies": { + "@tauri-apps/cli": "^2" + } +} diff --git a/frontends/desktop/src-tauri/Cargo.lock b/frontends/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..72a180b --- /dev/null +++ b/frontends/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5416 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "ga-desktop" +version = "0.1.0" +dependencies = [ + "dirs 5.0.1", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-single-instance", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.3", +] diff --git a/frontends/desktop/src-tauri/Cargo.toml b/frontends/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..17e13de --- /dev/null +++ b/frontends/desktop/src-tauri/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ga-desktop" +version = "0.1.0" +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["devtools"] } +tauri-plugin-single-instance = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "5" +rfd = "0.15" + +[lib] +name = "ga_desktop_lib" +crate-type = ["lib", "cdylib", "staticlib"] diff --git a/frontends/desktop/src-tauri/build.rs b/frontends/desktop/src-tauri/build.rs new file mode 100644 index 0000000..97ab5de --- /dev/null +++ b/frontends/desktop/src-tauri/build.rs @@ -0,0 +1,25 @@ +use std::process::Command; + +fn main() { + // Build identity used to decide whether a bridge already holding :14168 belongs to THIS + // build. commit hash + build timestamp → distinct on every build, even when the human + // version in tauri.conf.json is unchanged (so same-version re-publishes still take over + // a stale bridge). The bridge reports this back via GET /services/identity. + let commit = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "nogit".to_string()); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + println!("cargo:rustc-env=GA_BUILD_ID={}-{}", commit, stamp); + // Re-run when the checked-out commit changes so the id stays fresh. + println!("cargo:rerun-if-changed=../../../.git/HEAD"); + + tauri_build::build() +} diff --git a/frontends/desktop/src-tauri/capabilities/default.json b/frontends/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..cf96e34 --- /dev/null +++ b/frontends/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,24 @@ +{ + "identifier": "default", + "description": "Default capabilities for all windows", + "windows": ["main", "setup"], + "remote": { + "urls": ["http://127.0.0.1:14168", "http://localhost:14168"] + }, + "permissions": [ + "core:default", + "core:window:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-close", + "core:window:allow-get-all-windows", + "core:webview:default", + "allow-start-bridge", + "allow-start-bridge-with-config", + "allow-get-config", + "allow-export-mykey", + "allow-shortcut-should-ask", + "allow-shortcut-decide" + ] +} diff --git a/frontends/desktop/src-tauri/icons/128x128.png b/frontends/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..cfb5e7f Binary files /dev/null and b/frontends/desktop/src-tauri/icons/128x128.png differ diff --git a/frontends/desktop/src-tauri/icons/128x128@2x.png b/frontends/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..e9c9449 Binary files /dev/null and b/frontends/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/frontends/desktop/src-tauri/icons/32x32.png b/frontends/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..63a15c7 Binary files /dev/null and b/frontends/desktop/src-tauri/icons/32x32.png differ diff --git a/frontends/desktop/src-tauri/icons/icon.icns b/frontends/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..7fe7a9d Binary files /dev/null and b/frontends/desktop/src-tauri/icons/icon.icns differ diff --git a/frontends/desktop/src-tauri/icons/icon.ico b/frontends/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..f52ee35 Binary files /dev/null and b/frontends/desktop/src-tauri/icons/icon.ico differ diff --git a/frontends/desktop/src-tauri/icons/icon.png b/frontends/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..dad73e8 Binary files /dev/null and b/frontends/desktop/src-tauri/icons/icon.png differ diff --git a/frontends/desktop/src-tauri/permissions/bridge-commands.toml b/frontends/desktop/src-tauri/permissions/bridge-commands.toml new file mode 100644 index 0000000..cf78df3 --- /dev/null +++ b/frontends/desktop/src-tauri/permissions/bridge-commands.toml @@ -0,0 +1,29 @@ +[[permission]] +identifier = "allow-start-bridge" +description = "Spawn bridge from the services panel when bridge was stopped." +commands.allow = ["start_bridge"] + +[[permission]] +identifier = "allow-start-bridge-with-config" +description = "Spawn bridge from setup with explicit python/project paths." +commands.allow = ["start_bridge_with_config"] + +[[permission]] +identifier = "allow-get-config" +description = "Read saved bridge spawn configuration." +commands.allow = ["get_config"] + +[[permission]] +identifier = "allow-export-mykey" +description = "Save mykey.py via native file dialog." +commands.allow = ["export_mykey"] + +[[permission]] +identifier = "allow-shortcut-should-ask" +description = "Check whether to show the first-run desktop-shortcut prompt." +commands.allow = ["shortcut_should_ask"] + +[[permission]] +identifier = "allow-shortcut-decide" +description = "Persist the user's desktop-shortcut choice and create it if enabled." +commands.allow = ["shortcut_decide"] diff --git a/frontends/desktop/src-tauri/src/lib.rs b/frontends/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..69ab0e3 --- /dev/null +++ b/frontends/desktop/src-tauri/src/lib.rs @@ -0,0 +1,908 @@ +use std::process::{Command, Child, Stdio}; +use std::io::{BufRead, BufReader}; +use std::sync::Mutex; +use std::net::TcpStream; +use std::time::{Duration, Instant}; +use std::thread; +use std::path::PathBuf; +use tauri::Manager; + +#[cfg(windows)] +use std::os::windows::process::CommandExt; + +static BRIDGE_PROCESS: Mutex> = Mutex::new(None); + +/// Get project root (parent of frontends/) +fn project_root() -> PathBuf { + std::env::current_exe() + .expect("cannot get exe path") + .parent().expect("cannot get exe dir") // frontends/ + .parent().expect("cannot get project root") // project root + .to_path_buf() +} + +/// Directory next to which a self-contained bundle keeps its runtime/ folder. +/// Windows: the exe's folder. Linux: the .AppImage's folder ($APPIMAGE) when launched as an +/// AppImage (current_exe would otherwise point inside the read-only squashfs mount). +/// macOS portable package: the folder containing GenericAgent.app and runtime/. +fn bundle_anchor_dir() -> Option { + #[cfg(not(windows))] + { + if let Some(p) = std::env::var_os("APPIMAGE") { + if let Some(d) = PathBuf::from(p).parent() { + return Some(d.to_path_buf()); + } + } + } + + let exe = std::env::current_exe().ok()?; + + #[cfg(target_os = "macos")] + { + // current_exe() inside a bundle is: + // /GenericAgent.app/Contents/MacOS/GenericAgent + // Prefer the standard macOS layout where runtime is embedded in the app: + // GenericAgent.app/Contents/Resources/runtime/app/agentmain.py + // Fall back to the old portable layout for compatibility: + // /runtime/app/agentmain.py + let mut d = exe.parent(); + while let Some(dir) = d { + if dir.extension().and_then(|s| s.to_str()) == Some("app") { + let resources = dir.join("Contents").join("Resources"); + if resources.join("runtime").join("app").join("agentmain.py").exists() { + return Some(resources); + } + if let Some(parent) = dir.parent() { + return Some(parent.to_path_buf()); + } + } + d = dir.parent(); + } + } + + Some(exe.parent()?.to_path_buf()) +} + +/// Embedded interpreter inside the bundle's runtime/python (base python, before venv). +fn bundle_python() -> Option { + let root = bundle_root()?; + #[cfg(windows)] + let p = root.join("python").join("python.exe"); + #[cfg(not(windows))] + let p = root.join("python").join("bin").join("python3"); + if p.exists() { Some(p) } else { None } +} + +/// Find python executable: +/// 1. The embedded bundle python (runtime/python) — deps are installed directly into it +/// (no venv), and its path is resolved relative to the bundle anchor at runtime, so the +/// package stays relocatable (moving the folder doesn't break absolute venv paths). +/// 2. .portable/uv-python/ 下找 python.exe (Windows) 或 python3 (Unix) +/// 3. Fallback to system PATH +fn find_python() -> String { + if let Some(p) = bundle_python() { + return p.to_string_lossy().to_string(); + } + let root = project_root(); + let portable_python_dir = root.join(".portable").join("uv-python"); + + if portable_python_dir.exists() { + // uv installs python like: uv-python/cpython-3.12.x-windows-x86_64/python.exe + // We need to search for python.exe inside subdirectories + if let Ok(entries) = std::fs::read_dir(&portable_python_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + #[cfg(windows)] + { + let py = path.join("python.exe"); + if py.exists() { + return py.to_string_lossy().to_string(); + } + } + #[cfg(not(windows))] + { + let py = path.join("bin").join("python3"); + if py.exists() { + return py.to_string_lossy().to_string(); + } + } + } + } + } + } + + // Fallback: system PATH + #[cfg(windows)] + { "python".to_string() } + #[cfg(not(windows))] + { "python3".to_string() } +} + +/// Find the project directory (folder containing agentmain.py). +/// Bundle layout: /runtime/app/agentmain.py. Dev layout: walk up from the exe. +fn find_project_dir() -> Option { + // Bundle layout: source tucked under /runtime/app/ + if let Some(anchor) = bundle_anchor_dir() { + let app = anchor.join("runtime").join("app"); + if app.join("agentmain.py").exists() { + return Some(app.to_string_lossy().to_string()); + } + } + + // Dev/source layout: walk up to 8 levels from the exe location. + let exe = std::env::current_exe().ok()?; + let mut dir = Some(exe.parent()?); + for _ in 0..8 { + match dir { + Some(d) => { + if d.join("agentmain.py").exists() { + return Some(d.to_string_lossy().to_string()); + } + dir = d.parent(); + } + None => break, + } + } + None +} + +/// Settings file path: ~/.ga_desktop_settings.json +fn settings_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ga_desktop_settings.json") +} + +/// Read the settings file as a JSON object (empty object when missing/unparseable). +fn read_settings() -> serde_json::Map { + let path = settings_path(); + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(serde_json::Value::Object(m)) = serde_json::from_str(&content) { + return m; + } + } + serde_json::Map::new() +} + +/// Merge `updates` into the existing settings file and write it back, preserving any keys +/// we don't touch. The old code rewrote the file with only python_path/project_dir, which +/// would silently drop sibling keys like `desktop_shortcut`. Always go through here. +fn merge_settings(updates: serde_json::Value) { + let mut obj = read_settings(); + if let serde_json::Value::Object(m) = updates { + for (k, v) in m { + obj.insert(k, v); + } + } + let val = serde_json::Value::Object(obj); + if let Ok(text) = serde_json::to_string_pretty(&val) { + let _ = std::fs::write(settings_path(), text); + } +} + +/// Desktop-shortcut preference stored in settings under `desktop_shortcut`. +/// None = never asked (first run) +/// Some(true)/Some(false) = user's remembered choice. +fn read_shortcut_pref() -> Option { + read_settings().get("desktop_shortcut").and_then(|v| v.as_bool()) +} + +fn write_shortcut_pref(enabled: bool) { + merge_settings(serde_json::json!({ "desktop_shortcut": enabled })); +} + +/// Create (or overwrite) a desktop shortcut pointing at the CURRENT exe. Overwriting on every +/// enabled launch is what makes the portable bundle relocatable: move the folder, relaunch, and +/// the shortcut is rewritten to the new path. Windows-only (uses a .lnk via WScript.Shell). +#[cfg(windows)] +fn ensure_desktop_shortcut() { + let Ok(exe) = std::env::current_exe() else { return; }; + let Some(desktop) = dirs::desktop_dir() else { return; }; + let lnk = desktop.join("GenericAgent.lnk"); + let work_dir = exe.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| exe.clone()); + + let exe_s = exe.to_string_lossy().replace('\'', "''"); + let lnk_s = lnk.to_string_lossy().replace('\'', "''"); + let work_s = work_dir.to_string_lossy().replace('\'', "''"); + + // Build the shortcut via WScript.Shell COM, consistent with the existing powershell usage + // elsewhere in this file. No extra crate needed. + let script = format!( + "$ws = New-Object -ComObject WScript.Shell; \ + $sc = $ws.CreateShortcut('{lnk}'); \ + $sc.TargetPath = '{exe}'; \ + $sc.WorkingDirectory = '{work}'; \ + $sc.IconLocation = '{exe}'; \ + $sc.Save()", + lnk = lnk_s, exe = exe_s, work = work_s + ); + + let mut cmd = Command::new("powershell.exe"); + cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]); + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + let _ = cmd.status(); +} + +#[cfg(target_os = "linux")] +fn ensure_desktop_shortcut() { + // Launch target: the AppImage path when running as one, else the current exe. Writing the + // current path on every enabled launch keeps a relocated bundle's launcher valid. + let Some(target) = std::env::var_os("APPIMAGE").map(PathBuf::from) + .or_else(|| std::env::current_exe().ok()) else { return; }; + let exec = target.to_string_lossy().replace('"', ""); + // Linux .desktop Icon= needs an image file (or themed name), not the AppImage path. The CI + // ships GenericAgent.png next to the AppImage; fall back to a generic themed icon otherwise. + let icon = bundle_anchor_dir() + .map(|d| d.join("GenericAgent.png")) + .filter(|p| p.exists()) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| "application-x-executable".to_string()); + let entry = format!( + "[Desktop Entry]\nType=Application\nName=GenericAgent\nComment=GenericAgent Desktop\n\ + Exec=\"{exec}\"\nIcon={icon}\nTerminal=false\nCategories=Utility;Development;\n", + exec = exec, icon = icon + ); + let write_desktop = |path: &std::path::Path| { + if std::fs::write(path, &entry).is_ok() { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)); + } + }; + if let Some(home) = dirs::home_dir() { + let apps = home.join(".local/share/applications"); + let _ = std::fs::create_dir_all(&apps); + write_desktop(&apps.join("GenericAgent.desktop")); + } + if let Some(desktop) = dirs::desktop_dir() { + let _ = std::fs::create_dir_all(&desktop); + let f = desktop.join("GenericAgent.desktop"); + write_desktop(&f); + // GNOME marks unknown launchers "untrusted"; flag ours so it runs on double-click. Best effort. + let _ = Command::new("gio") + .args(["set", &f.to_string_lossy(), "metadata::trusted", "true"]) + .status(); + } +} + +#[cfg(target_os = "macos")] +fn ensure_desktop_shortcut() { + // The .app is the launchable unit; drop a symlink to it on the Desktop. + let Ok(exe) = std::env::current_exe() else { return; }; + let mut app: Option = None; + let mut d = exe.parent(); + while let Some(dir) = d { + if dir.extension().and_then(|s| s.to_str()) == Some("app") { app = Some(dir.to_path_buf()); break; } + d = dir.parent(); + } + let (Some(app), Some(desktop)) = (app, dirs::desktop_dir()) else { return; }; + let link = desktop.join("GenericAgent.app"); + let _ = std::fs::remove_file(&link); + let _ = std::os::unix::fs::symlink(&app, &link); +} + +#[cfg(all(not(windows), not(target_os = "linux"), not(target_os = "macos")))] +fn ensure_desktop_shortcut() {} + +/// First-run shortcut handling for portable bundles (all platforms). Self-heals the shortcut +/// path on every enabled launch (cheap, no UI). The first-run ASK is driven by the frontend +/// (see the `shortcut_should_ask` / `shortcut_decide` commands): a native dialog from this +/// background startup thread has no parent window and gets buried behind the main window on +/// first launch, so the prompt is owned by the web UI instead, which always renders on top. +fn maybe_setup_shortcut() { + if bundle_root().is_none() { + return; + } + // Only self-heal when the user already opted in. Never prompt here. + if read_shortcut_pref() == Some(true) { + ensure_desktop_shortcut(); + } +} + +/// Frontend asks whether to show the first-run "create desktop shortcut?" prompt. +/// True only on a portable bundle whose preference has never been set. +#[tauri::command] +fn shortcut_should_ask() -> bool { + bundle_root().is_some() && read_shortcut_pref().is_none() +} + +/// Frontend reports the user's choice. Persists it and creates the shortcut when enabled. +#[tauri::command] +fn shortcut_decide(create: bool) { + write_shortcut_pref(create); + if create { + ensure_desktop_shortcut(); + } +} + +/// True when this binary is running from inside a macOS .app bundle (packaged build). +/// Used to refuse stale ~/.ga_desktop_settings.json that could point at an old checkout +/// when App Translocation hides our own runtime/ from current_exe(). +#[cfg(target_os = "macos")] +fn running_inside_app_bundle() -> bool { + std::env::current_exe() + .ok() + .map(|p| { + p.components().any(|c| { + c.as_os_str().to_string_lossy().ends_with(".app") + }) + }) + .unwrap_or(false) +} + +/// Read config from settings file, or auto-discover and save. +/// Self-contained bundles always prefer their own runtime/app over stale user settings, +/// otherwise an old ~/.ga_desktop_settings.json can silently point the UI at a different checkout. +pub fn get_or_discover_config() -> (String, String) { + let path = settings_path(); + + if bundle_root().is_some() { + let python = find_python(); + let project = find_project_dir().unwrap_or_default(); + if !python.is_empty() && !project.is_empty() { + merge_settings(serde_json::json!({ + "python_path": python, + "project_dir": project + })); + return (python, project); + } + } + + // Try reading existing settings. + // On macOS, a packaged .app must never trust ~/.ga_desktop_settings.json: App + // Translocation can run the bundle from a random read-only copy where bundle_root() + // fails to see our own runtime/, and an old settings file would then silently point + // the bridge at a previously installed checkout. In that case fall through to + // auto-discovery (which still resolves the bundle via .app-relative search below). + #[cfg(target_os = "macos")] + let trust_settings = !running_inside_app_bundle(); + #[cfg(not(target_os = "macos"))] + let trust_settings = true; + + if trust_settings && path.exists() { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(val) = serde_json::from_str::(&content) { + let python = val.get("python_path") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let project = val.get("project_dir") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if !python.is_empty() && !project.is_empty() { + return (python, project); + } + } + } + } + + // Auto-discover + let python = find_python(); + let project = find_project_dir().unwrap_or_default(); + + // Save discovered config + if !python.is_empty() && !project.is_empty() { + merge_settings(serde_json::json!({ + "python_path": python, + "project_dir": project + })); + } + + (python, project) +} + +/// Self-contained bundle support dir: holds python/, wheels/, install_windows.ps1 and app/. +/// Typical portable layout keeps only the exe (+README) at the top level and tucks everything +/// else under /runtime/. Returns None when this is not a bundle (e.g. dev build). +fn bundle_root() -> Option { + let runtime = bundle_anchor_dir()?.join("runtime"); + if runtime.join("app").join("agentmain.py").exists() { + return Some(runtime); + } + None +} + +/// Marker written after a successful offline prepare. Lives under runtime/ so it travels +/// with the bundle: a relocated folder stays "prepared" (deps live in the embedded python, +/// which is itself relocatable) and won't re-run prepare. +fn prepared_marker() -> Option { + Some(bundle_root()?.join(".prepared")) +} + +/// True when this is a self-contained bundle whose python env has not been prepared yet +/// (embedded python present but deps not yet installed into it). +fn needs_first_run_prepare(project_dir: &str) -> bool { + if project_dir.is_empty() { return false; } + bundle_python().is_some() && prepared_marker().map(|m| !m.exists()).unwrap_or(false) +} + +/// Clear env vars a host launcher injects pointing at its own runtime. The Linux AppImage exports +/// PYTHONHOME/PYTHONPATH (-> bundled python crashes with "No module named 'encodings'") and +/// LD_LIBRARY_PATH (-> wrong shared libs). Our bundled python / prepare / bridge must run clean. +fn sanitize_bundle_env(cmd: &mut Command) { + cmd.env_remove("PYTHONHOME"); + cmd.env_remove("PYTHONPATH"); + cmd.env_remove("LD_LIBRARY_PATH"); + // Stamp the bridge we spawn with this build's id so a later app launch can tell whether the + // bridge holding :14168 is ours (see bridge_identity_matches / GET /services/identity). + cmd.env("GA_BUILD_ID", env!("GA_BUILD_ID")); +} + +/// Run the offline prepare (install_windows.ps1 -Mode PrepareOnly) using bundled python + wheels. +/// Streams the script's stdout and forwards GAPROGRESS markers to `report(pct, message)`. +/// Blocking; intended to run on a background thread. Writes ~/.ga_desktop_settings.json. +fn run_offline_prepare(project_dir: &str, report: &dyn Fn(i32, &str)) -> Result<(), String> { + let root = bundle_root().ok_or("cannot locate bundle root")?; + let wheels = root.join("wheels"); + + #[cfg(windows)] + let (script, py) = ( + root.join("install_windows.ps1"), + root.join("python").join("python.exe"), + ); + #[cfg(target_os = "macos")] + let (script, py) = ( + root.join("install_macos.sh"), + root.join("python").join("bin").join("python3"), + ); + #[cfg(all(not(windows), not(target_os = "macos")))] + let (script, py) = ( + root.join("install_linux.sh"), + root.join("python").join("bin").join("python3"), + ); + + if !script.exists() || !py.exists() || !wheels.exists() { + return Err(format!("prepare resources missing under {:?}", root)); + } + + #[cfg(windows)] + let mut cmd = { + let mut c = Command::new("powershell.exe"); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]) + .arg(&script) + .arg("-PythonPath").arg(&py) + .arg("-ProjectDir").arg(project_dir) + .arg("-WheelDir").arg(&wheels) + .arg("-ExtraPipPackages").arg("fastapi uvicorn websockets") + // -NoVenv: install deps straight into the embedded python (no venv) so the + // bundle is relocatable. See prepared_marker / find_python. + .args(["-Mode", "PrepareOnly", "-SkipNpmInstall", "-NoVenv"]); + c + }; + #[cfg(not(windows))] + let mut cmd = { + let mut c = Command::new("bash"); + c.arg(&script) + .arg("--python-path").arg(&py) + .arg("--project-dir").arg(project_dir) + .arg("--wheel-dir").arg(&wheels) + .arg("--extra-packages").arg("fastapi uvicorn websockets") + // --no-venv: install deps straight into the embedded python (no venv) so the + // bundle is relocatable. See prepared_marker / find_python. + .args(["--mode", "PrepareOnly", "--no-venv"]); + c + }; + + cmd.stdout(Stdio::piped()).stderr(Stdio::null()); + sanitize_bundle_env(&mut cmd); + #[cfg(windows)] + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + let mut child = cmd.spawn().map_err(|e| format!("failed to launch prepare: {}", e))?; + + // Forward the script's ASCII progress keys to the loading window, which localizes them + // (window.gaProgress maps key -> zh/en by navigator.language). + if let Some(out) = child.stdout.take() { + for line in BufReader::new(out).lines().flatten() { + if let Some(key) = line.trim().strip_prefix("GAPROGRESS|") { + match key.trim() { + "venv" => report(15, "venv"), + "deps" => report(45, "deps"), + "done" => report(90, "done"), + _ => {} + } + } + } + } + + let status = child.wait().map_err(|e| format!("prepare wait failed: {}", e))?; + if !status.success() { + return Err(format!("prepare exited with status {:?}", status.code())); + } + // Record success so later launches (and relocated copies) skip the prepare step. + if let Some(marker) = prepared_marker() { + let _ = std::fs::write(&marker, b"ok\n"); + } + Ok(()) +} + +/// GET /services/identity from a running bridge; returns the parsed JSON (or None when the +/// endpoint is absent — i.e. an older/foreign bridge). +fn bridge_reported_identity() -> Option { + use std::io::{Read, Write}; + let mut stream = TcpStream::connect_timeout( + &"127.0.0.1:14168".parse().unwrap(), + Duration::from_millis(800), + ).ok()?; + let _ = stream.set_read_timeout(Some(Duration::from_millis(800))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(600))); + let req = b"GET /services/identity HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"; + stream.write_all(req).ok()?; + let mut buf = Vec::new(); + let _ = stream.read_to_end(&mut buf); + let text = String::from_utf8_lossy(&buf); + let body = text.split("\r\n\r\n").nth(1)?; + serde_json::from_str(body.trim()).ok() +} + +fn norm_path(p: &str) -> String { + std::fs::canonicalize(p) + .map(|c| c.to_string_lossy().to_string()) + .unwrap_or_else(|_| p.to_string()) +} + +/// A running bridge is "ours" only when it serves the same install path AND was spawned by the +/// same build. The build id (commit+timestamp, see build.rs) changes on every build, so an +/// in-place upgrade or a same-version re-publish still counts as a different bridge → take over. +/// An old bridge with no /identity (None) or no build_id field ("") never matches → taken over. +fn bridge_identity_matches(project_dir: &str) -> bool { + let Some(id) = bridge_reported_identity() else { return false; }; + let reported_root = id.get("ga_root").and_then(|v| v.as_str()).unwrap_or(""); + let reported_build = id.get("build_id").and_then(|v| v.as_str()).unwrap_or(""); + if reported_build != env!("GA_BUILD_ID") { + return false; + } + let (a, b) = (norm_path(reported_root), norm_path(project_dir)); + #[cfg(windows)] + { a.eq_ignore_ascii_case(&b) } + #[cfg(not(windows))] + { a == b } +} + +/// Last resort when a stale bridge ignores POST /services/bridge/exit (e.g. an old build with +/// no such endpoint): force-kill whatever process is listening on :14168 so the new bridge can +/// bind it. Only called after an identity mismatch, so we never kill a bridge that is ours. +fn force_free_bridge_port() { + #[cfg(windows)] + { + // netstat -ano: last column is the PID for the :14168 LISTENING row. + if let Ok(out) = Command::new("netstat").args(["-ano", "-p", "tcp"]).output() { + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + if line.contains(":14168") && line.to_uppercase().contains("LISTENING") { + if let Some(pid) = line.split_whitespace().last() { + let mut c = Command::new("taskkill"); + c.args(["/F", "/PID", pid]); + c.creation_flags(0x08000000); + let _ = c.status(); + } + } + } + } + } + #[cfg(not(windows))] + { + // lsof prints the listening PIDs; kill -9 each. + if let Ok(out) = Command::new("lsof").args(["-ti", "tcp:14168", "-sTCP:LISTEN"]).output() { + for pid in String::from_utf8_lossy(&out.stdout).split_whitespace() { + let _ = Command::new("kill").args(["-9", pid]).status(); + } + } + } +} + +fn request_bridge_shutdown() { + use std::io::{Read, Write}; + let Ok(mut stream) = TcpStream::connect_timeout( + &"127.0.0.1:14168".parse().unwrap(), + Duration::from_millis(800), + ) else { + return; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(600))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(600))); + let req = b"POST /services/bridge/exit HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let _ = stream.write_all(req); + let _ = stream.read(&mut [0u8; 512]); +} + +fn takeover_stale_bridge(project_dir: &str) { + if project_dir.is_empty() || !is_bridge_running() { + return; + } + if bridge_identity_matches(project_dir) { + return; + } + eprintln!("[tauri] a different/stale bridge holds 127.0.0.1:14168; taking over"); + request_bridge_shutdown(); + let start = Instant::now(); + while is_bridge_running() && start.elapsed() < Duration::from_secs(10) { + thread::sleep(Duration::from_millis(200)); + } + // Old bridges have no /services/bridge/exit endpoint and ignore the request above — if the + // port is still held, force-kill the listener so our fresh bridge can bind it. + if is_bridge_running() { + eprintln!("[tauri] stale bridge did not exit; force-freeing :14168"); + force_free_bridge_port(); + let start = Instant::now(); + while is_bridge_running() && start.elapsed() < Duration::from_secs(5) { + thread::sleep(Duration::from_millis(200)); + } + } +} + +fn is_bridge_running() -> bool { + TcpStream::connect(("127.0.0.1", 14168)).is_ok() +} + +fn wait_for_port(port: u16, timeout: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return true; + } + thread::sleep(Duration::from_millis(100)); + } + false +} + +fn spawn_bridge_process(python_path: &str, project_dir: &str) -> Result<(), String> { + if is_bridge_running() { + return Ok(()); + } + let py = PathBuf::from(python_path); + let dir = PathBuf::from(project_dir); + let script = dir.join("frontends").join("desktop_bridge.py"); + if !script.exists() { + return Err(format!("desktop_bridge.py not found at {:?}", script)); + } + + let mut cmd = Command::new(&py); + cmd.arg(&script).current_dir(&dir); + sanitize_bundle_env(&mut cmd); + #[cfg(windows)] + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + let child = cmd.spawn().map_err(|e| format!("Failed to spawn: {}", e))?; + *BRIDGE_PROCESS.lock().unwrap() = Some(child); + Ok(()) +} + +fn show_bridge_window(app_handle: &tauri::AppHandle) { + if let Some(main_win) = app_handle.get_webview_window("main") { + let url = tauri::Url::parse("http://127.0.0.1:14168/").unwrap(); + let _ = main_win.navigate(url); + let _ = main_win.show(); + let _ = main_win.set_focus(); + } + if let Some(setup_win) = app_handle.get_webview_window("setup") { + let _ = setup_win.hide(); + } +} + +#[tauri::command] +fn start_bridge_with_config(app_handle: tauri::AppHandle, python_path: String, project_dir: String) -> Result<(), String> { + // Save to settings (merge so sibling keys like desktop_shortcut survive). + merge_settings(serde_json::json!({"python_path": python_path, "project_dir": project_dir})); + + spawn_bridge_process(&python_path, &project_dir)?; + + // Wait for port + if !wait_for_port(14168, Duration::from_secs(20)) { + return Err("Bridge did not become ready within 20s".into()); + } + + show_bridge_window(&app_handle); + Ok(()) +} + +#[tauri::command] +fn start_bridge(app_handle: tauri::AppHandle) -> Result<(), String> { + let (python_path, project_dir) = get_or_discover_config(); + spawn_bridge_process(&python_path, &project_dir)?; + if !wait_for_port(14168, Duration::from_secs(20)) { + return Err("Bridge did not become ready within 20s".into()); + } + show_bridge_window(&app_handle); + Ok(()) +} + +#[tauri::command] +fn get_config() -> (String, String) { + get_or_discover_config() +} + +#[tauri::command] +fn export_mykey(content: String) -> Result, String> { + let path = rfd::FileDialog::new() + .set_file_name("mykey.py") + .add_filter("Python", &["py"]) + .save_file(); + match path { + Some(p) => { + std::fs::write(&p, content.as_bytes()).map_err(|e| e.to_string())?; + Ok(Some(p.to_string_lossy().into_owned())) + } + None => Ok(None), + } +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let args: Vec = std::env::args().collect(); + let no_autostart = args.iter().any(|a| a == "--no-autostart"); + let dev_mode = args.iter().any(|a| a == "--dev"); + + let project_dir = find_project_dir().unwrap_or_default(); + let needs_prepare = needs_first_run_prepare(&project_dir); + + takeover_stale_bridge(&project_dir); + + let bridge_ok = is_bridge_running(); + let mut spawned_bridge = false; + // Skip the early spawn when a first-run prepare is required (no venv yet); + // the setup thread prepares the env first and then starts the bridge. + if !bridge_ok && !no_autostart && !needs_prepare { + // Try to start bridge with saved/discovered config + let (py_str, dir_str) = get_or_discover_config(); + let dir = PathBuf::from(&dir_str); + let script = dir.join("frontends").join("desktop_bridge.py"); + if script.exists() { + let mut cmd = Command::new(&py_str); + cmd.arg(&script).current_dir(&dir); + sanitize_bundle_env(&mut cmd); + #[cfg(windows)] + cmd.creation_flags(0x08000000); + if let Ok(child) = cmd.spawn() { + *BRIDGE_PROCESS.lock().unwrap() = Some(child); + spawned_bridge = true; + } + } + } + + tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(w) = app.get_webview_window("main") { + let _ = w.unminimize(); + let _ = w.show(); + let _ = w.set_focus(); + } + })) + .invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, shortcut_should_ask, shortcut_decide]) + .setup(move |app| { + // Show the loading window immediately so the first-run prepare isn't a blank screen. + // The window starts on loading.html (a local page), so no "connection refused" flash. + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + } + + let handle = app.handle().clone(); + let project_dir = project_dir.clone(); + thread::spawn(move || { + // Progress reporter: push status into the loading window (window.gaProgress). + let main_win = handle.get_webview_window("main"); + + let report = |pct: i32, msg: &str| { + if let Some(w) = &main_win { + let js = format!( + "window.gaProgress && window.gaProgress({}, {})", + pct, + serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string()) + ); + let _ = w.eval(&js); + } + }; + + // First-run (self-contained bundle): prepare the embedded python env offline, + // then start the bridge with the freshly created venv. + if needs_prepare { + report(5, "start"); + if let Err(e) = run_offline_prepare(&project_dir, &report) { + eprintln!("[tauri] first-run prepare failed: {}", e); + if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.show(); } + if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); } + return; + } + report(95, "starting"); + if !is_bridge_running() { + let (py_str, dir_str) = get_or_discover_config(); + let dir = PathBuf::from(&dir_str); + let script = dir.join("frontends").join("desktop_bridge.py"); + if script.exists() { + let mut cmd = Command::new(&py_str); + cmd.arg(&script).current_dir(&dir); + sanitize_bundle_env(&mut cmd); + #[cfg(windows)] + cmd.creation_flags(0x08000000); + if let Ok(child) = cmd.spawn() { + *BRIDGE_PROCESS.lock().unwrap() = Some(child); + } + } + } + } + + // First run (prepare) and cold bridge start may take a while; allow up to 60s. + let wait = if needs_prepare || spawned_bridge { + Duration::from_secs(60) + } else { + Duration::from_secs(2) + }; + let bridge_ready = wait_for_port(14168, wait); + + if bridge_ready { + // The bridge auto-starts conductor + scheduler itself (on_startup), so we do + // NOT probe their ports here: that would self-detect the bridge's own + // just-started extras and falsely report "ports busy". + if !wait_for_port(14168, Duration::from_secs(15)) { + eprintln!("[tauri] bridge not reachable before navigate"); + if let Some(w) = &main_win { + let msg = "无法连接 bridge (127.0.0.1:14168),请关闭程序后重试。"; + let js = format!( + "alert({})", + serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string()) + ); + let _ = w.eval(&js); + } + return; + } + // Navigate to the bridge HTTP only after it is ready. + if let Some(w) = handle.get_webview_window("main") { + if let Ok(url) = tauri::Url::parse("http://127.0.0.1:14168/") { + let _ = w.navigate(url); + } + if dev_mode { + w.open_devtools(); + } else { + // Disable F5/F12/Ctrl+R/right-click in production + let _ = w.eval(r#" + document.addEventListener('keydown', function(e) { + if (e.key === 'F12' || e.key === 'F5' || + (e.ctrlKey && e.key === 'r') || + (e.ctrlKey && e.shiftKey && e.key === 'I')) { + e.preventDefault(); + } + }); + document.addEventListener('contextmenu', function(e) { + e.preventDefault(); + }); + "#); + } + let _ = w.show(); + let _ = w.set_focus(); + } + if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.hide(); } + // App is up and reachable: ask-once / self-heal the desktop shortcut. + // Runs last so it never blocks the loading/navigation path. + maybe_setup_shortcut(); + } else { + // Bridge never came up -> let the user fix paths in the setup window. + if let Some(sw) = handle.get_webview_window("setup") { + if dev_mode { sw.open_devtools(); } + let _ = sw.show(); + } + if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); } + } + }); + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + let label = window.label(); + if label == "main" { + // Persistent backend: closing the window does NOT stop the bridge or its + // services, so relaunching attaches to the warm backend on 14168. + window.app_handle().exit(0); + } else if label == "setup" { + // Setup closed -> exit if main is not visible + if let Some(main_win) = window.app_handle().get_webview_window("main") { + if !main_win.is_visible().unwrap_or(false) { + window.app_handle().exit(0); + } + } else { + window.app_handle().exit(0); + } + } + } + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/frontends/desktop/src-tauri/src/main.rs b/frontends/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..ab57d1c --- /dev/null +++ b/frontends/desktop/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + ga_desktop_lib::run() +} diff --git a/frontends/desktop/src-tauri/tauri.conf.json b/frontends/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..e74082c --- /dev/null +++ b/frontends/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,41 @@ +{ + "productName": "GenericAgent", + "version": "0.1.0", + "identifier": "com.genericagent.app", + "build": { + "frontendDist": "../static" + }, + "app": { + "withGlobalTauri": true, + "security": { + "csp": null + }, + "windows": [ + { + "title": "GenericAgent", + "label": "main", + "width": 1280, + "height": 800, + "resizable": true, + "maximized": false, + "visible": false, + "url": "loading.html" + }, + { + "title": "GenericAgent — Setup", + "label": "setup", + "width": 600, + "height": 580, + "resizable": false, + "visible": false, + "center": true, + "url": "fallback.html" + } + ] + }, + "bundle": { + "active": true, + "targets": ["nsis", "dmg"], + "icon": ["icons/icon.ico", "icons/icon.png", "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png"] + } +} diff --git a/frontends/desktop/static/app.js b/frontends/desktop/static/app.js new file mode 100644 index 0000000..b6ad62b --- /dev/null +++ b/frontends/desktop/static/app.js @@ -0,0 +1,6203 @@ +// GenericAgent 桌面版 —— bridge 适配 + 业务 UI(HTTP 命令 / WS 状态 / i18n)。 +// 文案全部走 i18n:静态用 data-i18n / data-i18n-ph / data-i18n-title, +// 动态用 t(key)。dev 标注层与发给 agent 的预设 prompt 不进 UI 字典。 +'use strict'; + +/* ═══════════════ 端口/URL 常量 ═══════════════ + bridge / conductor 的端口和 origin 都集中在这里。要换端口、要切同源、 + 要让 bridge 代理 conductor —— 改这一块即可,下面所有 URL 引用全都跟着走。 + *_ORIGIN 不带尾巴 path,调用方自己拼 "/sessions" "/ws" 等。 */ +const BRIDGE_PORT = 14168; +const CONDUCTOR_PORT = 8900; +const BRIDGE_ORIGIN = `${location.protocol}//${location.hostname}:${BRIDGE_PORT}`; +const BRIDGE_WS_ORIGIN = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.hostname}:${BRIDGE_PORT}`; +const CONDUCTOR_ORIGIN = `${location.protocol}//${location.hostname}:${CONDUCTOR_PORT}`; +const CONDUCTOR_WS_ORIGIN = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.hostname}:${CONDUCTOR_PORT}`; + +/* ═══════════════ 进程状态 store ═══════════════ */ +const _serviceById = {}; +const _serviceListeners = new Set(); + +function _serviceList() { + return Object.values(_serviceById).sort((a, b) => String(a.id).localeCompare(String(b.id))); +} + +function _serviceNotify() { + const items = _serviceList(); + for (const cb of _serviceListeners) { + try { cb(items, _serviceById); } catch (e) { console.error('[service-store]', e); } + } +} + +const gaServiceStore = { + applySnapshot(services) { + for (const k of Object.keys(_serviceById)) delete _serviceById[k]; + for (const s of services || []) { + if (s && s.id) _serviceById[s.id] = s; + } + _serviceNotify(); + }, + applyChanged(service) { + if (service && service.id) _serviceById[service.id] = service; + _serviceNotify(); + }, + onServices(cb) { + _serviceListeners.add(cb); + cb(_serviceList(), _serviceById); + return () => _serviceListeners.delete(cb); + }, + list: _serviceList, + get: (id) => _serviceById[id], +}; + +let bridgeUiOffline = false; + +/* ═══════════════ Bridge 适配(HTTP 命令 + WS 状态) ═══════════════ */ +(function initGaBridge() { + const listeners = new Map(); + let ws = null; + let cachedBridgeReady = null; + let wsRetries = 0; + let wsRetryTimer = null; + const bridgeBase = BRIDGE_ORIGIN; + const wsUrl = `${BRIDGE_WS_ORIGIN}/ws`; + + function on(channel, cb) { + if (typeof cb !== 'function') return () => {}; + if (!listeners.has(channel)) listeners.set(channel, new Set()); + listeners.get(channel).add(cb); + if (channel === 'bridge-ready' && cachedBridgeReady) { + try { cb(cachedBridgeReady); } catch (err) { console.error('[ga bridge] replay bridge-ready', err); } + } + return () => listeners.get(channel)?.delete(cb); + } + + function emit(channel, payload) { + if (channel === 'bridge-ready') cachedBridgeReady = payload; + const set = listeners.get(channel); + if (!set) return; + for (const cb of Array.from(set)) { + try { cb(payload); } catch (err) { console.error('[ga bridge]', channel, err); } + } + } + + function handleServiceWs(msg) { + if (msg.type === 'services.snapshot') gaServiceStore.applySnapshot(msg.services); + else if (msg.type === 'service.changed') gaServiceStore.applyChanged(msg.service); + emit('service-state', msg); + } + + const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + const tauriInvoke = (name, args = {}) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) throw new Error('Tauri IPC is not available'); + return invoke(name, args); + }; + + async function http(path, options = {}) { + const headers = Object.assign({}, options.headers || {}); + const init = Object.assign({}, options, { headers }); + if (init.body && typeof init.body !== 'string') { + headers['Content-Type'] = headers['Content-Type'] || 'application/json'; + init.body = JSON.stringify(init.body); + } + const res = await fetch(`${bridgeBase}${path}`, init); + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; } + if (!res.ok) { + const err = new Error((data && (data.error || data.message)) || `${res.status} ${res.statusText}`); + err.status = res.status; + err.data = data; + throw err; + } + return data; + } + + async function waitBridgeStatus(timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + let lastErr = null; + while (Date.now() < deadline) { + try { + const status = await http('/status'); + wsRetries = 0; + connectWs(); + return status; + } catch (err) { + lastErr = err; + await sleep(350); + } + } + throw lastErr || new Error('Bridge did not become ready'); + } + + function connectWs() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + if (wsRetryTimer) { clearTimeout(wsRetryTimer); wsRetryTimer = null; } + try { + ws = new WebSocket(wsUrl); + ws.addEventListener('open', () => { wsRetries = 0; emit('bridge-log', 'WS connected'); }); + ws.addEventListener('message', (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch (_) { return; } + if (msg.type === 'bridge-ready') emit('bridge-ready', msg); + else if (msg.type === 'services.snapshot' || msg.type === 'service.changed') handleServiceWs(msg); + else if (msg.type === 'session-state') emit('bridge-notification', msg); + else if (msg.type === 'bridge-log') emit('bridge-log', msg.payload || msg); + else if (msg.type === 'bridge-error') emit('bridge-error', msg.payload || msg); + }); + ws.addEventListener('close', () => { emit('bridge-closed', { reason: 'ws-closed' }); scheduleWsReconnect(); }); + ws.addEventListener('error', () => emit('bridge-error', { type: 'ws-error', message: 'WebSocket error' })); + } catch (err) { + emit('bridge-error', { type: 'ws-error', message: err.message || String(err) }); + scheduleWsReconnect(); + } + } + + /* WS 自动重连(指数退避,封顶 30s)。手机浏览器后台会被 OS 掐 WS, + 不重连的话回到前台还是死连接。`visibilitychange` 那一段是回前台立刻重连。 */ + function scheduleWsReconnect() { + if (wsRetryTimer) clearTimeout(wsRetryTimer); + if (typeof document !== 'undefined' && document.hidden) return; // 后台等回前台再连 + const delay = Math.min(30000, 1000 * Math.pow(2, wsRetries)); + wsRetries++; + wsRetryTimer = setTimeout(() => { wsRetryTimer = null; connectWs(); }, delay); + } + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (!document.hidden && (!ws || ws.readyState >= WebSocket.CLOSING)) { + wsRetries = 0; connectWs(); + } + }); + } + + async function rpc(method, params = {}) { + switch (method) { + case 'app/status': return http('/status'); + case 'app/config/get': return http('/config'); + case 'app/config/save': return http('/config', { method: 'POST', body: params || {} }); + case 'get/model-profiles': return http('/model-profiles'); + case 'session/new': return http('/session/new', { method: 'POST', body: params || {} }); + case 'session/prompt': { + const sid = params.sessionId || params.id || params.bridgeSessionId; + if (!sid) throw new Error('session/prompt missing sessionId'); + return http(`/session/${encodeURIComponent(sid)}/prompt`, { method: 'POST', body: params || {} }); + } + case 'session/poll': { + const sid = params.sessionId || params.id || params.bridgeSessionId; + if (!sid) throw new Error('session/poll missing sessionId'); + const after = params.afterId ?? params.after ?? 0; + const limit = params.limit ?? 200; + return http(`/session/${encodeURIComponent(sid)}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`); + } + case 'session/cancel': { + const sid = params.sessionId || params.id || params.bridgeSessionId; + if (!sid) throw new Error('session/cancel missing sessionId'); + return http(`/session/${encodeURIComponent(sid)}/cancel`, { method: 'POST', body: params || {} }); + } + case 'app/path/open': return http('/path/open', { method: 'POST', body: params || {} }); + case 'services/start': { + const id = params.id; + if (!id) throw new Error('services/start missing id'); + return http('/services/start', { method: 'POST', body: { id } }); + } + case 'services/stop': { + const id = params.id; + if (!id) throw new Error('services/stop missing id'); + return http('/services/stop', { method: 'POST', body: { id } }); + } + case 'services/logs': { + const id = params.id; + if (!id) throw new Error('services/logs missing id'); + const tail = params.tail ?? 200; + return http(`/services/logs?id=${encodeURIComponent(id)}&tail=${encodeURIComponent(tail)}`); + } + case 'services/panel': return http('/services/panel'); + case 'services/bridge/exit': return http('/services/bridge/exit', { method: 'POST' }); + case 'services/mykey/get': return http('/services/mykey'); + case 'services/mykey/save': return http('/services/mykey', { method: 'POST', body: params || {} }); + case 'app/path/selectGaRoot': return http('/config'); + case 'list_continuable_sessions': return { sessions: [] }; + case 'restore_session': throw new Error('restore_session is not implemented in web2 bridge'); + default: throw new Error(`Unknown RPC method: ${method}`); + } + } + + async function startService(id) { + try { + const res = await rpc('services/start', { id }); + if (res.service) gaServiceStore.applyChanged(res.service); + return res; + } catch (e) { + if (e.data && e.data.service) gaServiceStore.applyChanged(e.data.service); + throw e; + } + } + + async function stopService(id) { + const res = await rpc('services/stop', { id }); + if (res.service) gaServiceStore.applyChanged(res.service); + return res; + } + + async function spawnBridge() { + connectWs(); + try { + const status = await http('/status'); + bridgeUiOffline = false; + return status; + } catch (_) { + await tauriInvoke('start_bridge'); + const status = await waitBridgeStatus(); + bridgeUiOffline = false; + return status; + } + } + + async function exitBridge() { + const res = await rpc('services/bridge/exit'); + cachedBridgeReady = null; + if (ws) { + try { ws.close(); } catch (_) {} + } + return res; + } + + window.ga = { + platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32', + startBridge: async () => { connectWs(); return http('/status'); }, + spawnBridge, + stopBridge: async () => ({ ok: true }), + exitBridge, + checkStatus: () => rpc('app/status', {}), + getConfig: () => rpc('app/config/get', {}), + saveConfig: (cfg) => rpc('app/config/save', cfg || {}), + getModelProfiles: () => rpc('get/model-profiles', {}), + selectGaRoot: () => rpc('app/path/selectGaRoot', {}), + openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }), + openMykey: () => rpc('app/path/open', { kind: 'mykey' }), + startService, + stopService, + getServiceLogs: (id, tail = 200) => rpc('services/logs', { id, tail }), + getServicePanel: () => rpc('services/panel', {}), + getMykeyContent: () => rpc('services/mykey/get', {}), + saveMykeyContent: (content) => rpc('services/mykey/save', { content }), + tauriInvoke, + setBridgeUiOffline: (offline) => { bridgeUiOffline = !!offline; }, + pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }), + rpc, + onBridgeMessage: (cb) => on('bridge-message', cb), + onBridgeNotification: (cb) => on('bridge-notification', cb), + onBridgeError: (cb) => on('bridge-error', cb), + onBridgeClosed: (cb) => on('bridge-closed', cb), + onBridgeReady: (cb) => on('bridge-ready', cb), + onBridgeLog: (cb) => on('bridge-log', cb), + onServiceState: (cb) => on('service-state', cb), + onOpenSearch: (cb) => on('open-search', cb), + }; + + connectWs(); + http('/status').then(status => emit('bridge-ready', status)) + .catch(err => emit('bridge-error', { type: 'http-error', message: err.message || String(err) })); +})(); + +/* ═══════════════ i18n ═══════════════ */ +const I18N = { + zh: { + 'app.title': 'GenericAgent 桌面版', + 'brand.sub': '桌面终端', + 'nav.chat': '聊天', 'nav.services': '后台服务', 'nav.channels': '消息通道', 'nav.status': '状态面板', + 'nav.collab': '指挥家', 'nav.token': '用量', + 'foot.settings': '配置', 'foot.ver': 'GenericAgent · 桌面版', + 'chat.startTitle': '开始对话', 'chat.startSub': '直接输入,或点预设功能一键启动', + 'preset.butler.t': '指挥家', 'preset.butler.d': '复杂任务自动拆解,只需查看进度和简报', + 'preset.plan.t': 'Plan 模式', 'preset.plan.d': '加载 Plan SOP,按探索→规划→执行→验证流程', + 'preset.goal.t': 'Goal 模式', 'preset.goal.d': '设定目标,自主完成', + 'preset.autonomous.t': '自主行动', 'preset.autonomous.d': '按 SOP 规划/执行任务,产出报告(reflect/autonomous.py 同源)', + 'preset.hive.t': 'Hive 协作', 'preset.hive.d': '多 worker 协同攻坚', + 'preset.review.t': '深度复核', 'preset.review.d': '挑刺式质量把关', + 'preset.findwork.t': '找点事做', 'preset.findwork.d': '分析当前情况,推荐一批让你感兴趣的 TODO', + 'preset.mine.t': '我的·周报', 'preset.mine.d': '自定义:抓本周提交并写周报', + 'preset.add.t': '自定义', 'preset.add.d': '任意一句话存为功能', + 'composer.placeholder': 'GA 能帮你做些什么?', + 'search.placeholder': '搜索会话…', 'conv.new': '新对话', + 'ctx.pin': '置顶', 'ctx.unpin': '取消置顶', 'ctx.rename': '重命名', 'ctx.del': '删除', + 'common.close': '关闭', 'common.more': '更多', 'common.optional': '选填', 'common.save': '保存', + 'modal.preset': '预设功能', 'modal.addModel': '添加模型', 'modal.editModel': '编辑模型', 'modal.settings': '配置', + 'modal.customPreset': '自定义预设', + 'modal.editCustomPreset': '编辑任务', + 'customPreset.titlePh': '标题,例如「写周报」', + 'customPreset.promptPh': 'Prompt 内容,发送时会作为消息提交', + 'customPreset.empty': '标题和 Prompt 不能为空', + 'customPreset.removeTitle': '删除', + 'customPreset.editTitle': '编辑', + 'builtinPreset.restoreBtn': '恢复默认预设', + 'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有模型配置(mykey.py)', 'set.exportMykey': '导出当前模型配置', 'set.serviceManager': '后台服务管理', + 'shortcut.askConfirm': '是否在桌面创建 GenericAgent 快捷方式?', + 'appearance.light': '浅色', 'appearance.dark': '深色', + 'set.noModels': '暂无模型,点击下方添加', + 'lang.zh': '简体中文', 'lang.en': 'English', + 'model.name': '备注', 'model.namePh': '会显示在模型列表', + 'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': '留空则保持原 Key 不变', + 'model.apibase': 'API 地址', 'model.apibasePh': 'https://.../v1/messages', + 'model.protocol': '协议', 'model.protocolPick': '请选择…', 'model.protocolOai': 'OpenAI 兼容 (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)', + 'model.stream': '响应方式', 'model.streamOn': '流式', 'model.streamOff': '非流式', + 'model.model': '模型', 'model.modelPh': 'model 参数名', + 'model.modelHint': '须与中转站/官方文档中的 model 字段完全一致', + 'model.retries': '重试 (次)', 'model.connTimeout': '连接超时 (s)', 'model.readTimeout': '读取超时 (s)', + 'model.save': '保存', 'common.cancel': '取消', 'common.confirm': '确认', 'common.edit': '编辑', 'common.delete': '删除', + 'pq.title': '快速接入官方模型', 'pq.sub': '填好 API Key 即可使用', 'pq.toggle': '展开 / 收起', + 'pq.deepseekDesc': '官方 API · OpenAI 兼容', 'pq.qwenDesc': '通义千问 · 阿里云百炼', + 'guide.step1': '点击下方链接,登录后创建并复制 API Key', + 'guide.step2': '把 Key 粘贴到下方「API Key」输入框', + 'guide.step3': '点击保存,即可在模型列表中选用', + 'guide.prefillTip': '已为你预填 API 地址、协议与模型,可按需修改', + 'guide.getKey': '获取 {name} 的 API Key', 'guide.copy': '复制链接', 'guide.copied': '链接已复制', + 'err.modelSave': '保存失败', 'err.modelRequired': '请填写模型、API Key 和 API 地址', + 'err.modelDelete': '删除失败', 'err.modelDeleteLast': '至少保留一个模型', + 'confirm.modelDelete': '确定删除该模型配置?', + 'model.aggregation': '渠道组(自动故障转移)', 'model.aggregationShort': '渠道组', 'model.aggregationDesc': '按顺序尝试,失败自动切换到下一个', + 'model.emptyMixin': '尚未加入模型', + 'model.addToMixin': '加入渠道组', 'model.inMixin': '已在渠道组', 'model.removeFromMixin': '移出渠道组', 'model.alreadyInMixin': '已在渠道组中', 'model.dragReorder': '拖拽调整顺序', + 'err.mixinFailed': '操作失败', + 'page.services.title': '后台服务', 'page.services.sub': 'IM 消息通道与后台进程,集中查看、启停与日志', + 'page.channels.title': '消息通道', 'page.channels.sub': '后台 IM 进程:列表、启停与日志(同 hub.pyw)', + 'page.status.title': '状态面板', 'page.status.sub': 'hub.pyw 管理的后台进程/服务,集中查看与启停', + 'page.collab.title': '指挥家', 'page.collab.sub': '交代目标,自动拆活与跟进', + 'collab.progressTitle': '分工进度', + 'collab.progressEmpty': '还没有任务在执行。告诉指挥家你的目标后,这里会显示拆分后的处理进度。', + 'collab.placeholder': '请对指挥家描述你想完成的目标', + 'collab.guideTitle': '把要完成的事告诉指挥家', + 'collab.guideWhen': '适合需要多步处理、要花一些时间才能完成的目标。日常聊天和快问快答,请用左侧「聊天」。', + 'collab.guideStep1t': '描述目标', + 'collab.guideStep1d': '在聊天框里写下你想做的事,发给指挥家', + 'collab.guideStep2t': '自动拆解', + 'collab.guideStep2d': '指挥家自动拆解、分配任务,实时监督和调度', + 'collab.guideStep3t': '交付摘要', + 'collab.guideStep3d': '指挥家根据执行状态,呈上任务简报', + 'collab.guideStep4t': '随时调整', + 'collab.guideStep4d': '随时补充要求或细节,指挥家都会处理', + 'collab.chipProgress': '现在进展如何?', + 'collab.chipPause': '先暂停当前任务', + 'collab.chipSummary': '总结一下目前的结果', + 'collab.showProgressTitle': '查看分工进度', + 'collab.statRunning': '进行中', + 'collab.statDone': '已完成', + 'collab.plusMenu': '更多操作', + 'collab.switchMode': '切换模式', + 'collab.typing': '指挥家正在处理', + 'collab.offline': '无法连接指挥家服务,请确认后端已启动。', + 'collab.retry': '重试', + 'collab.reconnect': '连接断开,正在重连… 已保留上次任务进度。', + 'collab.reconnectIn': '{n} 秒后重试', + 'collab.stRunning': '执行中', 'collab.stReported': '已回报', 'collab.stPaused': '已暂停', + 'collab.stFailed': '遇到问题', 'collab.stTerminated': '已终止', + 'collab.summaryRunning': '正在处理中…', 'collab.summaryWait': '等待回报', + 'collab.taskFallback': '任务 {n}', + 'collab.timeJust': '刚刚', + 'collab.timeSec': '{n} 秒前', + 'collab.timeMin': '{n} 分钟前', + 'collab.timeHr': '{n} 小时前', + 'collab.timeDay': '{n} 天前', + 'page.token.title': '用量', 'page.token.sub': '每会话与累计用量及缓存率', + 'status.connecting': '正在连接…', 'status.ready': '服务在线', 'status.running': '处理中', + 'status.disconnected': '服务离线', 'status.stopped': '已停止', 'status.idle': '待命', + 'conv.emptyList': '暂无会话,点「+ 新对话」开始', 'conv.defaultTitle': '新对话', + 'err.bridge': '服务未响应', 'err.newSession': '新建会话失败', 'err.poll': '轮询失败', 'err.stop': '停止失败', + 'err.interruptTimeout': '等待上一轮停止超时,请稍后再试', + 'sys.interruptPrev.hint': '已停止上一轮,正在处理新消息', + 'chat.interrupting': '正在停止上一轮…', + 'chat.sessionLoading': '正在加载会话…', + 'sys.stopRequested': '已请求停止', + 'slash.help': '可用命令:\n/new 新会话 /clear 清屏 /stop 停止 /settings 设置', + 'slash.unknown': '未知命令', + 'upload.hint': '上传文件:选择 / 拖拽 / 粘贴', + 'upload.button': '上传文件', + 'upload.tooLarge': '文件过大或数量超限', 'upload.empty': '跳过空文件', + 'upload.failed': '上传失败', + 'err.charLimit': '已达字数上限({n}),发送时将自动截断', 'err.charLimitReached': '已达字数上限({n})', 'err.numMax': '不能超过 {n}', + 'file.openFailed': '无法打开文件', + 'file.kindGeneric': '文件', + 'file.kindDoc': '文档', + 'file.kindSheet': '表格', + 'file.kindSlide': '幻灯片', + 'file.kindCode': '代码', + 'file.kindArchive': '压缩包', + 'file.kindAudio': '音频', + 'file.kindVideo': '视频', + 'upload.removeTitle': '移除', + 'upload.dropHint': '松开以上传文件', + 'lightbox.closeTitle': '关闭', + 'fold.thinking': '思考', 'fold.tool': '工具调用', 'fold.toolResult': '工具结果', 'fold.llm': 'LLM Running', 'fold.turn': '第 {n} 轮', + 'plan.header': '计划 ({done}/{total})', 'plan.complete': '✓ 计划完成 ({n}/{n})', + 'plan.running': '计划执行中', 'plan.completeTitle': '计划完成', + 'plan.placeholder': '计划模式已激活', 'plan.waiting': '等待写入 {path} …', 'plan.overflow': '还有 {n} 项', + 'plan.current': '当前', 'plan.collapse': '收起', 'plan.expand': '展开', 'plan.details': '详情', + 'plan.capsuleRunning': '运行中', 'plan.capsuleComplete': '已完成', + 'timing.elapsed': '已运行 {t}', + 'model.auto': '自动选择', + 'model.menuLabel': '选择模型', + 'chip.plan': 'Plan', + 'chip.auto': 'Auto', + 'ch.wechat': '微信', 'ch.wecom': '企业微信', 'ch.lark': '飞书', 'ch.dingtalk': '钉钉', + 'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord', + 'ch.loading': '加载中…', 'ch.empty': '未发现 IM 进程脚本', + 'ch.logEmpty': '暂无日志', + 'err.channelLoad': '加载失败', 'err.channelStart': '启动失败', 'err.channelStop': '停止失败', + 'err.mykeyImport': '导入模型配置失败', + 'err.mykeyExport': '导出模型配置失败', + 'err.channelNotConfigured': '请先在 mykey.py 中配置该平台', + 'sys.channelStarted': '已启动', 'sys.channelStopped': '已停止', + 'modal.channelLogs': '进程日志', + 'modal.mykeyConfig': 'mykey.py 配置', + 'sys.configSaved': '配置已保存', + 'sys.mykeyImported': '模型配置已导入', + 'sys.mykeyExported': '模型配置已导出', + 'st.starting': '启动中…', 'st.stopping': '停止中…', 'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常', + 'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动', 'act.exit': '退出', + 'act.copy': '复制', 'act.copied': '已复制', 'act.copyTex': 'TeX', 'act.send': '发送', + 'proc.imbotWechat': 'imbot · 微信', 'proc.imbotDing': 'imbot · 钉钉', 'proc.scheduler': '定时任务调度', 'proc.conductor': '指挥家', + 'cm.scheduling': '调度中', 'cm.running': '执行中', 'cm.idleSt': '空闲', + 'cm.master': '已派 3 子任务', 'cm.w1': '子任务:抓取数据', 'cm.w2': '子任务:复核结果', 'cm.sub': '等待派单', + 'tok.total': '累计', 'tok.cost': '缓存率', 'tok.today': '今日', 'tok.tabAll': '聊天', 'tok.tabConductor': '指挥家', 'tok.condTotal': '指挥家累计', 'tok.condCurrent': '指挥家本次', 'tok.condTip': '指挥家消耗不计入聊天累计', 'tok.condOffline': '指挥家服务离线', 'tok.disclaimer': '不同 API 网站的计费价格可能会有差异,请以实际网站为准。', + 'tok.colSession': '会话', 'tok.colIn': '输入', 'tok.colOut': '输出', 'tok.colCacheW': '缓存写入', 'tok.colCache': '缓存读取', 'tok.colCost': '成本', + 'tok.from': '从', 'tok.to': '到', 'tok.reset': '重置', 'tok.noData': '暂无记录', 'tok.deleted': '此会话已删除', + 'tok.pricingUnknown': '⚠ 此模型计费规则尚未明确,按默认估算', + 'tok.priceInput': '输入: $', 'tok.priceOutput': '输出: $', + 'tok.priceCacheW': '缓存写入: $', 'tok.priceCacheR': '缓存读取: $', + 'presetPrompt.goal': '进入 Goal 模式:读 L3 goal mode SOP,自主达成我接下来描述的目标。', + 'presetPrompt.plan': '进入 Plan 模式:先读 memory/plan_sop.md,按其中「探索→规划→执行→验证」流程,等我接下来描述要做的任务。', + 'presetPrompt.autonomous': '🤖 进入自主行动模式:阅读 memory/autonomous_operation_sop.md,按 SOP 选取或规划任务,独立执行并产出报告。', + 'presetPrompt.hive': '启动 Goal Hive 模式:按 hive SOP 拉起多个 worker 协同完成我接下来的目标。', + 'presetPrompt.review': '进入监察者模式:对刚才的产出严格挑刺、逐项复核并报告问题。', + 'presetPrompt.findwork': '按照自主行动的规划部分,充分分析我的情况,给我生成一批 TODO,务必让我感兴趣。', + 'presetPrompt.mine': '抓取本周的 git 提交并写一份周报。', + 'ask.banner': 'GA 等你回答', + 'ask.replyHint': '在下方输入框回复', + 'ask.placeholderOpen': '在此输入你的回答… (Enter 发送)', + }, + en: { + 'app.title': 'GenericAgent Desktop', + 'brand.sub': 'Desktop terminal', + 'nav.chat': 'Chat', 'nav.services': 'Services', 'nav.channels': 'Channels', 'nav.status': 'Status', + 'nav.collab': 'Conductor', 'nav.token': 'Usage', + 'foot.settings': 'Settings', 'foot.ver': 'GenericAgent · Desktop', + 'chat.startTitle': 'Start a conversation', 'chat.startSub': 'Type a message, or pick a preset', + 'preset.butler.t': 'Conductor', 'preset.butler.d': 'Auto-decompose complex tasks; just check progress and briefings', + 'preset.plan.t': 'Plan mode', 'preset.plan.d': 'Load Plan SOP — explore→plan→execute→verify', + 'preset.goal.t': 'Goal mode', 'preset.goal.d': 'Set a goal, run autonomously', + 'preset.autonomous.t': 'Autonomous mode', 'preset.autonomous.d': 'Plan/execute tasks per SOP and produce reports (same as reflect/autonomous.py)', + 'preset.hive.t': 'Hive', 'preset.hive.d': 'Multi-worker collaboration', + 'preset.review.t': 'Deep review', 'preset.review.d': 'Strict quality check', + 'preset.findwork.t': 'Find me work', 'preset.findwork.d': 'Analyze my context and suggest a batch of interesting TODOs', + 'preset.mine.t': 'My · Weekly', 'preset.mine.d': 'Custom: weekly report from commits', + 'preset.add.t': 'Custom', 'preset.add.d': 'Save any prompt as a function', + 'composer.placeholder': 'What can GA do for you?', + 'search.placeholder': 'Search chats…', 'conv.new': 'New chat', + 'ctx.pin': 'Pin', 'ctx.unpin': 'Unpin', 'ctx.rename': 'Rename', 'ctx.del': 'Delete', + 'common.close': 'Close', 'common.more': 'More', 'common.optional': 'Optional', 'common.save': 'Save', + 'modal.preset': 'Presets', 'modal.addModel': 'Add model', 'modal.editModel': 'Edit model', 'modal.settings': 'Settings', + 'modal.customPreset': 'Custom preset', + 'modal.editCustomPreset': 'Edit task', + 'customPreset.titlePh': 'Title, e.g. "Weekly report"', + 'customPreset.promptPh': 'Prompt body — sent as the message when clicked', + 'customPreset.empty': 'Title and Prompt cannot be empty', + 'customPreset.removeTitle': 'Delete', + 'customPreset.editTitle': 'Edit', + 'builtinPreset.restoreBtn': 'Restore defaults', + 'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config (mykey.py)', 'set.exportMykey': 'Export current model config', 'set.serviceManager': 'Service manager', + 'shortcut.askConfirm': 'Create a desktop shortcut for GenericAgent?', + 'appearance.light': 'Light', 'appearance.dark': 'Dark', + 'set.noModels': 'No models yet — add one below', + 'lang.zh': '简体中文', 'lang.en': 'English', + 'model.name': 'Note', 'model.namePh': 'Shown in the model list', + 'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': 'Leave blank to keep the current key', + 'model.apibase': 'API base URL', 'model.apibasePh': 'https://.../v1/messages', + 'model.protocol': 'Protocol', 'model.protocolPick': 'Select…', 'model.protocolOai': 'OpenAI-compatible (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)', + 'model.stream': 'Response', 'model.streamOn': 'Stream', 'model.streamOff': 'Non-stream', + 'model.model': 'Model', 'model.modelPh': 'model parameter name', + 'model.modelHint': 'Must match the model field in your provider docs exactly', + 'model.retries': 'Retries (×)', 'model.connTimeout': 'Connect (s)', 'model.readTimeout': 'Read (s)', + 'model.save': 'Save', 'common.cancel': 'Cancel', 'common.confirm': 'Confirm', 'common.edit': 'Edit', 'common.delete': 'Delete', + 'pq.title': 'Quick connect a model', 'pq.sub': 'Add your API key to get started', 'pq.toggle': 'Expand / collapse', + 'pq.deepseekDesc': 'Official API · OpenAI-compatible', 'pq.qwenDesc': 'Tongyi Qwen · Aliyun Bailian', + 'guide.step1': 'Open the link, sign in, then create & copy your API key', + 'guide.step2': 'Paste the key into the “API Key” field below', + 'guide.step3': 'Click Save — then pick it from the model list', + 'guide.prefillTip': 'API base, protocol and model are pre-filled — edit if needed', + 'guide.getKey': 'Get your {name} API key', 'guide.copy': 'Copy link', 'guide.copied': 'Link copied', + 'err.modelSave': 'Save failed', 'err.modelRequired': 'Model, API Key and base URL are required', + 'err.modelDelete': 'Delete failed', 'err.modelDeleteLast': 'At least one model is required', + 'confirm.modelDelete': 'Delete this model profile?', + 'model.aggregation': 'Channel group (auto failover)', 'model.aggregationShort': 'Channel group', 'model.aggregationDesc': 'Tries in order, switches to the next on failure', + 'model.emptyMixin': 'No models added yet', + 'model.addToMixin': 'Add to channel', 'model.inMixin': 'In channel', 'model.removeFromMixin': 'Remove from channel', 'model.alreadyInMixin': 'Already in the channel', 'model.dragReorder': 'Drag to reorder', + 'err.mixinFailed': 'Operation failed', + 'page.services.title': 'Services', 'page.services.sub': 'IM channels and background processes — view, start/stop, logs', + 'page.channels.title': 'Channels', 'page.channels.sub': 'Background IM processes: list, start/stop, logs (hub.pyw style)', + 'page.status.title': 'Status', 'page.status.sub': 'Background processes/services managed by hub.pyw', + 'page.collab.title': 'Conductor', 'page.collab.sub': 'Describe a goal — split, delegate, and follow up', + 'collab.progressTitle': 'Progress', + 'collab.progressEmpty': 'No tasks running yet. After you describe a goal to Conductor, split tasks will appear here.', + 'collab.placeholder': 'Describe the goal you want to accomplish', + 'collab.guideTitle': 'Tell Conductor what you want done', + 'collab.guideWhen': 'Best for multi-step goals that take a while. For everyday chat and quick questions, use Chat in the sidebar.', + 'collab.guideStep1t': 'Describe your goal', + 'collab.guideStep1d': 'Write what you want done in the chat box and send it to Conductor', + 'collab.guideStep2t': 'Auto breakdown', + 'collab.guideStep2d': 'Conductor breaks down, assigns, monitors, and coordinates', + 'collab.guideStep3t': 'Summary', + 'collab.guideStep3d': 'Conductor delivers a briefing based on execution status', + 'collab.guideStep4t': 'Adjust anytime', + 'collab.guideStep4d': 'Add requirements or details anytime — Conductor handles them', + 'collab.chipProgress': 'How is it going?', + 'collab.chipPause': 'Pause current tasks', + 'collab.chipSummary': 'Summarize progress so far', + 'collab.showProgressTitle': 'View task progress', + 'collab.statRunning': 'Running', + 'collab.statDone': 'Done', + 'collab.plusMenu': 'More actions', + 'collab.switchMode': 'Switch mode', + 'collab.typing': 'Conductor is working', + 'collab.offline': 'Cannot reach the service. Make sure the backend is running.', + 'collab.retry': 'Retry', + 'collab.reconnect': 'Disconnected — reconnecting… Your last progress is kept.', + 'collab.reconnectIn': 'Retry in {n}s', + 'collab.stRunning': 'Running', 'collab.stReported': 'Reported', 'collab.stPaused': 'Paused', + 'collab.stFailed': 'Issue', 'collab.stTerminated': 'Ended', + 'collab.summaryRunning': 'Working…', 'collab.summaryWait': 'Awaiting report', + 'collab.taskFallback': 'Task {n}', + 'collab.timeJust': 'just now', + 'collab.timeSec': '{n}s ago', + 'collab.timeMin': '{n}m ago', + 'collab.timeHr': '{n}h ago', + 'collab.timeDay': '{n}d ago', + 'page.token.title': 'Usage', 'page.token.sub': 'Per-session and total usage & cache rate', + 'status.connecting': 'Connecting…', 'status.ready': 'Service online', 'status.running': 'Working…', + 'status.disconnected': 'Service offline', 'status.stopped': 'Stopped', 'status.idle': 'Standby', + 'conv.emptyList': 'No chats yet — click “+ New chat”', 'conv.defaultTitle': 'New chat', + 'err.bridge': 'Service not responding', 'err.newSession': 'Failed to create session', 'err.poll': 'Polling failed', 'err.stop': 'Stop failed', + 'err.interruptTimeout': 'Timed out waiting for the previous reply to stop — try again', + 'sys.interruptPrev.hint': 'Previous reply stopped — processing new message', + 'chat.interrupting': 'Stopping previous reply…', + 'chat.sessionLoading': 'Loading conversation…', + 'sys.stopRequested': 'Stop requested', + 'slash.help': 'Commands:\n/new new chat /clear clear /stop stop /settings settings', + 'slash.unknown': 'Unknown command', + 'upload.hint': 'Upload file: pick / drag / paste', + 'upload.button': 'Upload file', + 'upload.tooLarge': 'File too large or limit reached', 'upload.empty': 'Skipped empty file', + 'upload.failed': 'Upload failed', + 'err.charLimit': 'Character limit reached ({n}), text will be truncated on send', 'err.charLimitReached': 'Character limit reached ({n})', 'err.numMax': 'Cannot exceed {n}', + 'file.openFailed': 'Cannot open file', + 'file.kindGeneric': 'File', + 'file.kindDoc': 'Document', + 'file.kindSheet': 'Spreadsheet', + 'file.kindSlide': 'Slides', + 'file.kindCode': 'Code', + 'file.kindArchive': 'Archive', + 'file.kindAudio': 'Audio', + 'file.kindVideo': 'Video', + 'upload.removeTitle': 'Remove', + 'upload.dropHint': 'Drop to upload files', + 'lightbox.closeTitle': 'Close', + 'fold.thinking': 'Thinking', 'fold.tool': 'Tool call', 'fold.toolResult': 'Tool result', 'fold.llm': 'LLM Running', 'fold.turn': 'Turn {n}', + 'plan.header': 'Plan ({done}/{total})', 'plan.complete': '✓ Plan complete ({n}/{n})', + 'plan.running': 'Running plan', 'plan.completeTitle': 'Plan complete', + 'plan.placeholder': 'Plan mode activated', 'plan.waiting': 'waiting for {path} …', 'plan.overflow': '+{n} more', + 'plan.current': 'Now', 'plan.collapse': 'Collapse', 'plan.expand': 'Expand', 'plan.details': 'Details', + 'plan.capsuleRunning': 'Running', 'plan.capsuleComplete': 'Done', + 'timing.elapsed': 'Elapsed {t}', + 'model.auto': 'Auto', + 'model.menuLabel': 'Select model', + 'chip.plan': 'Plan', + 'chip.auto': 'Auto', + 'ch.wechat': 'WeChat', 'ch.wecom': 'WeCom', 'ch.lark': 'Lark', 'ch.dingtalk': 'DingTalk', + 'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord', + 'ch.loading': 'Loading…', 'ch.empty': 'No IM process scripts found', + 'ch.logEmpty': 'No log output yet', + 'err.channelLoad': 'Failed to load', 'err.channelStart': 'Start failed', 'err.channelStop': 'Stop failed', + 'err.mykeyImport': 'Failed to import model config', + 'err.mykeyExport': 'Failed to export model config', + 'err.channelNotConfigured': 'Configure this platform in mykey.py first', + 'sys.channelStarted': 'Started', 'sys.channelStopped': 'Stopped', + 'modal.channelLogs': 'Process logs', + 'modal.mykeyConfig': 'mykey.py', + 'sys.configSaved': 'Configuration saved', + 'sys.mykeyImported': 'Model config imported', + 'sys.mykeyExported': 'Model config exported', + 'st.starting': 'Starting…', 'st.stopping': 'Stopping…', 'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error', + 'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start', 'act.exit': 'Exit', + 'act.copy': 'Copy', 'act.copied': 'Copied', 'act.copyTex': 'TeX', 'act.send': 'Send', + 'proc.imbotWechat': 'imbot · WeChat', 'proc.imbotDing': 'imbot · DingTalk', 'proc.scheduler': 'Scheduler', 'proc.conductor': 'Conductor', + 'cm.scheduling': 'Scheduling', 'cm.running': 'Running', 'cm.idleSt': 'Idle', + 'cm.master': 'Dispatched 3 subtasks', 'cm.w1': 'Subtask: fetch data', 'cm.w2': 'Subtask: review results', 'cm.sub': 'Waiting for tasks', + 'tok.total': 'Total', 'tok.cost': 'Cache rate', 'tok.today': 'Today', 'tok.tabAll': 'Chat', 'tok.tabConductor': 'Conductor', 'tok.condTotal': 'Conductor Total', 'tok.condCurrent': 'Conductor Current', 'tok.condTip': 'Conductor usage is not included in chat totals', 'tok.condOffline': 'Service offline', 'tok.disclaimer': 'Pricing may vary by API provider. Please refer to the actual website.', + 'tok.colSession': 'Session', 'tok.colIn': 'Input', 'tok.colOut': 'Output', 'tok.colCacheW': 'Cache write', 'tok.colCache': 'Cache read', 'tok.colCost': 'Cost', + 'tok.from': 'From', 'tok.to': 'To', 'tok.reset': 'Reset', 'tok.noData': 'No records', 'tok.deleted': 'Session deleted', + 'tok.pricingUnknown': '⚠ Pricing not confirmed, using defaults', + 'tok.priceInput': 'Input: $', 'tok.priceOutput': 'Output: $', + 'tok.priceCacheW': 'Cache write: $', 'tok.priceCacheR': 'Cache read: $', + 'presetPrompt.goal': 'Enter Goal mode: read the L3 goal-mode SOP and autonomously achieve the goal I describe next.', + 'presetPrompt.plan': 'Enter Plan mode: first read memory/plan_sop.md, follow its explore→plan→execute→verify flow, and wait for the task I describe next.', + 'presetPrompt.autonomous': '🤖 Enter autonomous mode: read memory/autonomous_operation_sop.md, follow the SOP to pick or plan a task, execute independently, and produce a report.', + 'presetPrompt.hive': 'Start Goal Hive mode: per the hive SOP, spawn multiple workers to collaboratively achieve the goal I describe next.', + 'presetPrompt.review': 'Enter reviewer mode: strictly scrutinize the previous output, review item by item and report issues.', + 'presetPrompt.findwork': 'Following the autonomous planning section, analyze my situation thoroughly and generate a batch of TODOs that genuinely interest me.', + 'presetPrompt.mine': 'Collect this week\'s git commits and write a weekly report.', + 'ask.banner': 'GA is waiting for your answer', + 'ask.replyHint': 'Reply in the input below', + 'ask.placeholderOpen': 'Type your answer here… (Enter to send)', + }, +}; +const LANGS = ['zh', 'en']; +const STORE = { lang: 'ga_lang', theme: 'ga_theme', appearance: 'ga_appearance', plain: 'ga_plain', fontSize: 'ga_font_size', llmNo: 'ga_llm_no' }; +const APPEARANCE_IDS = ['light', 'dark']; +const CHAT_FONT_MIN = 10; +const CHAT_FONT_MAX = 20; +const CHAT_FONT_DEFAULT = 14; +const CHAT_FONT_LEGACY = { sm: 12, md: 14, lg: 16 }; +const HLJS_THEME_BASE = 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/'; + +function normalizeChatFontSize(value) { + if (typeof value === 'string' && CHAT_FONT_LEGACY[value]) return CHAT_FONT_LEGACY[value]; + const n = parseInt(value, 10); + if (Number.isFinite(n)) return Math.min(CHAT_FONT_MAX, Math.max(CHAT_FONT_MIN, n)); + return CHAT_FONT_DEFAULT; +} + +function bootUiFromDom() { + const root = document.documentElement; + const out = { lang: 'zh', theme: '1', appearance: 'light', plainUi: false, chatFontSize: CHAT_FONT_DEFAULT }; + if (root.lang === 'en') out.lang = 'en'; + if (root.dataset.theme) out.theme = root.dataset.theme; + if (APPEARANCE_IDS.includes(root.dataset.appearance)) out.appearance = root.dataset.appearance; + if (out.appearance === 'light' && root.dataset.plain === '1') out.plainUi = true; + if (root.dataset.chatFont) out.chatFontSize = normalizeChatFontSize(root.dataset.chatFont); + return out; +} +let { lang, theme, appearance, plainUi, chatFontSize } = bootUiFromDom(); + +function syncHljsTheme() { + const link = document.getElementById('hljs-theme'); + if (link) link.href = HLJS_THEME_BASE + (appearance === 'dark' ? 'github-dark.min.css' : 'github.min.css'); + document.querySelectorAll('.bubble.md pre code').forEach(block => { + if (typeof hljs !== 'undefined') hljs.highlightElement(block); + }); +} + +/** 服务端 ui 落盘后的本地镜像,仅供 index.html 内联脚本首帧防闪;不是真相源。 */ +function syncBootCache() { + localStorage.setItem(STORE.lang, lang); + localStorage.setItem(STORE.theme, theme); + localStorage.setItem(STORE.appearance, appearance); + localStorage.setItem(STORE.fontSize, String(chatFontSize)); + if (plainUi) localStorage.setItem(STORE.plain, '1'); + else localStorage.removeItem(STORE.plain); + localStorage.setItem(STORE.llmNo, String(state.llmNo)); +} +async function persistUiPrefs() { + try { + await window.ga.saveConfig({ + config: { lang, theme, appearance, plain: plainUi, llmNo: state.llmNo, fontSize: chatFontSize }, + }); + syncBootCache(); + } catch (_) {} +} +const bridgeHost = () => BRIDGE_ORIGIN; +async function bridgeFetch(path, opts = {}) { + const headers = { ...(opts.headers || {}) }; + const init = { ...opts, headers }; + if (init.body && typeof init.body !== 'string') { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(init.body); + } + const res = await fetch(`${bridgeHost()}${path}`, init); + let data = {}; + try { data = await res.json(); } catch (_) {} + if (!res.ok) throw new Error(data.error || data.message || res.statusText); + return data; +} +function t(key) { return (I18N[lang] && I18N[lang][key]) || (I18N.zh[key]) || key; } +window.gaT = t; +document.addEventListener('collab:running-count', e => { + const b = document.getElementById('collab-badge'); + if (!b) return; + const n = e.detail?.count || 0; + b.hidden = !n; + b.textContent = n ? (n > 9 ? '9+' : String(n)) : ''; +}); +function optionalPh(key) { + const sep = (lang === 'en') ? ', ' : ','; + return `${t('common.optional')}${sep}${t(key)}`; +} +function applyI18n() { + document.documentElement.lang = (lang === 'en') ? 'en' : 'zh-CN'; + document.title = t('app.title'); + document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); }); + document.querySelectorAll('[data-i18n-ph]').forEach(el => { + const phKey = el.dataset.i18nPh; + const val = el.hasAttribute('data-optional-ph') ? optionalPh(phKey) : t(phKey); + if (el.isContentEditable) el.setAttribute('data-ph', val); // contenteditable 用 :empty::before 显示 + else el.setAttribute('placeholder', val); + }); + document.querySelectorAll('[data-i18n-title]').forEach(el => { el.setAttribute('title', t(el.dataset.i18nTitle)); }); + renderLangList(); + // 语言切换后重算激活模型 chip 文案;若当前会话已有渠道组运行态模型,保留运行态而非退回首选项 + const _ap = (state.modelProfiles || []).find(p => (p.id ?? 0) === state.llmNo); + if (_ap) state.modelName = modelDisplayName(_ap); + if (_ap?.kind === 'mixin' && state.liveModel?.sessionId === state.activeId) applyLiveModel(state.liveModel); + else if (typeof updateModelChip === 'function') updateModelChip(); + window.gaRefreshModelGuide?.(); + window.collabRetranslate?.(); + syncAskUserUi(); +} +// 语言对应国旗 SVG(en 用美国旗,按要求) +const FLAGS = { + zh: '', + en: '', +}; +function renderLangList() { + const box = document.getElementById('lang-list'); + if (!box) return; + box.innerHTML = ''; + LANGS.forEach(code => { + const row = document.createElement('label'); + row.className = 'model-row' + (lang === code ? ' sel' : ''); + row.innerHTML = `${FLAGS[code] || ''}${escapeHtml(t('lang.' + code))}`; + row.addEventListener('click', (e) => { e.preventDefault(); selectLang(code); }); + box.appendChild(row); + }); +} +function selectLang(code) { + if (!LANGS.includes(code) || lang === code) return; + lang = code; + applyI18n(); + renderSessionList(); + refreshStatusLabel(); + updateModelChip(); + renderSettingsModels(); + if (typeof renderAllPresets === 'function') renderAllPresets(); + if (isServicesPageActive()) refreshServicesPanel(); + void persistUiPrefs(); +} +function syncChatFontSegments(value) { + document.querySelectorAll('.chat-font-seg').forEach(el => { + const v = parseInt(el.dataset.value, 10); + el.classList.toggle('on', v <= value); + el.classList.toggle('cur', v === value); + }); + const stepper = document.getElementById('chat-font-stepper'); + if (stepper) { + stepper.setAttribute('aria-valuenow', String(value)); + stepper.setAttribute('aria-valuetext', `${value}px`); + } +} +function chatFontFromPointer(clientX) { + const segs = document.getElementById('chat-font-segments'); + if (!segs) return chatFontSize; + const rect = segs.getBoundingClientRect(); + const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + return CHAT_FONT_MIN + Math.round(ratio * (CHAT_FONT_MAX - CHAT_FONT_MIN)); +} +function initChatFontStepper() { + const segs = document.getElementById('chat-font-segments'); + if (!segs || segs.childElementCount) return; + for (let i = CHAT_FONT_MIN; i <= CHAT_FONT_MAX; i++) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'chat-font-seg'; + btn.dataset.value = String(i); + btn.tabIndex = -1; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + applyChatFontSize(i); + }); + segs.appendChild(btn); + } + const stepper = document.getElementById('chat-font-stepper'); + if (!stepper || stepper.dataset.bound) return; + stepper.dataset.bound = '1'; + let dragging = false; + const pick = (clientX, persist) => applyChatFontSize(chatFontFromPointer(clientX), { persist }); + stepper.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + dragging = true; + stepper.setPointerCapture(e.pointerId); + pick(e.clientX, false); + }); + stepper.addEventListener('pointermove', (e) => { + if (!dragging) return; + pick(e.clientX, false); + }); + const endDrag = (e, persist) => { + if (!dragging) return; + dragging = false; + try { stepper.releasePointerCapture(e.pointerId); } catch (_) {} + pick(e.clientX, persist); + }; + stepper.addEventListener('pointerup', (e) => endDrag(e, true)); + stepper.addEventListener('pointercancel', (e) => endDrag(e, false)); + stepper.addEventListener('keydown', (e) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + e.preventDefault(); + applyChatFontSize(chatFontSize - 1); + } else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + e.preventDefault(); + applyChatFontSize(chatFontSize + 1); + } else if (e.key === 'Home') { + e.preventDefault(); + applyChatFontSize(CHAT_FONT_MIN); + } else if (e.key === 'End') { + e.preventDefault(); + applyChatFontSize(CHAT_FONT_MAX); + } + }); +} +function applyChatFontSize(size, { persist } = { persist: true }) { + chatFontSize = normalizeChatFontSize(size); + document.documentElement.dataset.chatFont = String(chatFontSize); + document.documentElement.style.setProperty('--chat-font', `${chatFontSize}px`); + const label = document.getElementById('chat-font-value'); + if (label) label.textContent = `${chatFontSize}px`; + syncChatFontSegments(chatFontSize); + if (persist) void persistUiPrefs(); +} +function applyTheme(id, { persist } = { persist: true }) { + // 主题选色已下线,只保留灰色亮色主题(--accent 在 styles.css 里硬编码)。 + // 函数保留可调用,只把 dataset.theme 固定到 '1' 兼容旧 localStorage。 + theme = '1'; + document.documentElement.dataset.theme = '1'; + if (persist) void persistUiPrefs(); +} +function syncPlainSwitch() { + const row = document.getElementById('plain-ui-row'); + const sw = document.getElementById('plain-ui-switch'); + if (!row || !sw) return; + const show = appearance === 'light'; + row.hidden = !show; + sw.setAttribute('aria-checked', plainUi ? 'true' : 'false'); +} +function applyAppearance(nextApp, nextPlain, { persist } = { persist: true }) { + appearance = APPEARANCE_IDS.includes(nextApp) ? nextApp : 'light'; + if (appearance === 'light') plainUi = !!nextPlain; + else plainUi = false; + document.documentElement.dataset.appearance = appearance; + if (plainUi) document.documentElement.dataset.plain = '1'; + else delete document.documentElement.dataset.plain; + document.querySelectorAll('#appearance-seg .appear-card').forEach(el => { + const on = el.dataset.appearance === appearance; + el.classList.toggle('sel', on); + el.setAttribute('aria-checked', on ? 'true' : 'false'); + }); + syncPlainSwitch(); + syncHljsTheme(); + if (persist) void persistUiPrefs(); +} + +/* ═══════════════ 侧边栏导航 ═══════════════ */ +const nav = document.getElementById('nav'); +const pages = document.querySelectorAll('#pages .page'); +let currentPage = 'chat'; +function gaGoPage(key) { + const item = nav?.querySelector(`.nav-item[data-page="${key}"]`); + if (!item) return; + currentPage = key; + nav.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n === item)); + pages.forEach(p => p.classList.toggle('active', p.dataset.page === key)); + renderSessionList(); + window.gaSetActiveFileComposer?.(key === 'collab' ? 'collab' : 'chat'); + if (key === 'collab') window.collabInit?.(); +} +window.gaGoPage = gaGoPage; +nav.addEventListener('click', (e) => { + const item = e.target.closest('.nav-item'); + if (!item) return; + gaGoPage(item.dataset.page); +}); + +/* ═══════════════ 弹窗开关 ═══════════════ */ +const openModal = (id) => { const m = document.getElementById(id); if (m) m.hidden = false; }; +window.gaOpenModal = openModal; +const closeModals = () => document.querySelectorAll('.modal').forEach(m => { + m.hidden = true; + m.querySelectorAll('.field-limit-hint').forEach(h => h.style.display = 'none'); +}); +const bindClick = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('click', fn); }; +function openServiceManagerFromSettings() { + closeModals(); + gaGoPage('services'); + setSvcTab('status'); + void loadStatusPanel(); +} +bindClick('add-model-btn', (e) => { + e.stopPropagation(); + openAddModelForm(); +}); +bindClick('settings-btn', (e) => { e.stopPropagation(); openSettings(); }); +bindClick('settings-services-btn', (e) => { e.stopPropagation(); openServiceManagerFromSettings(); }); + +const importMykeyInput = document.getElementById('import-mykey-input'); +async function importMykeyFromFile(file) { + if (!file) return; + const text = await file.text(); + if (!text.trim()) throw new Error(t('err.mykeyImport')); + await window.ga.saveMykeyContent(text); + await loadModelProfiles(); +} +bindClick('import-mykey-btn', (e) => { + e.stopPropagation(); + if (importMykeyInput) importMykeyInput.click(); +}); +if (importMykeyInput) { + importMykeyInput.addEventListener('change', async () => { + const file = importMykeyInput.files && importMykeyInput.files[0]; + importMykeyInput.value = ''; + if (!file) return; + try { + await importMykeyFromFile(file); + showChanToast(t('sys.mykeyImported'), '', 'ok'); + } catch (err) { + showChanToast(t('err.mykeyImport'), err.message || String(err), 'err'); + } + }); +} +async function exportMykeyToDir() { + const res = await window.ga.getMykeyContent(); + const content = (res && res.content) ? String(res.content) : ''; + if (!content.trim()) throw new Error(t('err.mykeyExport')); + // WebView2:独立缓存 + 无目录选择/下载;走 Tauri 原生另存为 + if (window.__TAURI__?.core?.invoke) { + const path = await window.ga.tauriInvoke('export_mykey', { content }); + if (!path) return; + showChanToast(t('sys.mykeyExported'), path, 'ok'); + return; + } + if (typeof window.showDirectoryPicker === 'function') { + const dir = await window.showDirectoryPicker(); + const handle = await dir.getFileHandle('mykey.py', { create: true }); + const writable = await handle.createWritable(); + await writable.write(content); + await writable.close(); + showChanToast(t('sys.mykeyExported'), '', 'ok'); + return; + } + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'mykey.py'; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + showChanToast(t('sys.mykeyExported'), '', 'ok'); +} +bindClick('export-mykey-btn', async (e) => { + e.stopPropagation(); + try { + await exportMykeyToDir(); + } catch (err) { + if (err && (err.name === 'AbortError' || err.code === 20)) return; + showChanToast(t('err.mykeyExport'), err.message || String(err), 'err'); + } +}); +// 侧边栏「快速接入」:点击官方模型按钮 → 打开预填好的添加模型表单 +const pqEl = document.getElementById('provider-quickstart'); +if (pqEl) pqEl.addEventListener('click', (e) => { + const btn = e.target.closest('.pq-btn[data-provider]'); + if (!btn) return; + e.preventDefault(); e.stopPropagation(); + openAddModelFormForProvider(btn.dataset.provider); +}); +// 「快速接入」卡片折叠/展开(向下箭头),状态记忆到 localStorage +const pqToggle = document.getElementById('pq-toggle'); +if (pqEl && pqToggle) { + const applyPq = (collapsed) => { + pqEl.classList.toggle('collapsed', collapsed); + pqToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + }; + let pqCollapsed = false; + try { pqCollapsed = localStorage.getItem('ga_pq_collapsed') === '1'; } catch (_) {} + applyPq(pqCollapsed); + const togglePq = (e) => { + if (e) e.stopPropagation(); + pqCollapsed = !pqEl.classList.contains('collapsed'); + applyPq(pqCollapsed); + try { localStorage.setItem('ga_pq_collapsed', pqCollapsed ? '1' : '0'); } catch (_) {} + }; + pqToggle.addEventListener('click', togglePq); + pqToggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePq(); } + }); +} +// 接入指引:复制获取 API Key 的链接 +bindClick('model-guide-copy', (e) => { + e.preventDefault(); e.stopPropagation(); + const link = document.getElementById('model-guide-link'); + const url = link ? link.href : ''; + if (!url || !navigator.clipboard) return; + navigator.clipboard.writeText(url).then(() => showChanToast(t('guide.copied'), '', 'ok')).catch(() => {}); +}); +bindClick('preset-btn', (e) => { e.stopPropagation(); openModal('preset-modal'); }); +document.querySelectorAll('.modal').forEach(m => + m.addEventListener('click', (e) => { + if (e.target.closest('[data-close]')) { + m.hidden = true; + m.querySelectorAll('.field-limit-hint').forEach(h => h.style.display = 'none'); + } + })); +document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModals(); }); + +function showConfirmDialog({ title, message, okText, okKind = 'primary', cancelText } = {}) { + const modal = document.getElementById('confirm-modal'); + if (!modal) return Promise.resolve(false); + const titleEl = document.getElementById('confirm-title'); + const msgEl = document.getElementById('confirm-message'); + const okBtn = document.getElementById('confirm-ok'); + const cancelBtn = document.getElementById('confirm-cancel'); + if (titleEl) titleEl.textContent = title || t('common.confirm'); + if (msgEl) msgEl.textContent = message || ''; + if (cancelBtn) cancelBtn.textContent = cancelText || t('common.cancel'); + if (okBtn) { + okBtn.textContent = okText || t('common.confirm'); + okBtn.classList.toggle('danger', okKind === 'danger'); + okBtn.classList.toggle('primary', okKind !== 'danger'); + } + modal.hidden = false; + return new Promise(resolve => { + let done = false; + const finish = (yes) => { + if (done) return; + done = true; + modal.hidden = true; + cleanup(); + resolve(yes); + }; + const onOk = (e) => { e.preventDefault(); e.stopPropagation(); finish(true); }; + const onCancel = (e) => { e.preventDefault(); e.stopPropagation(); finish(false); }; + const onClose = (e) => { if (e.target.closest('[data-close]')) finish(false); }; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); finish(false); } }; + const cleanup = () => { + okBtn?.removeEventListener('click', onOk); + cancelBtn?.removeEventListener('click', onCancel); + modal.removeEventListener('click', onClose, true); + document.removeEventListener('keydown', onKey, true); + }; + okBtn?.addEventListener('click', onOk); + cancelBtn?.addEventListener('click', onCancel); + modal.addEventListener('click', onClose, true); + document.addEventListener('keydown', onKey, true); + okBtn?.focus(); + }); +} + +/* ═══════════════ Markdown ═══════════════ */ +if (typeof marked !== 'undefined') { + marked.setOptions({ gfm: true, breaks: true, mangle: false, headerIds: false }); +} +const ALLOWED_URI_RE = /^(https?:|mailto:|tel:|#|\/)/i; +function escapeHtml(s) { + const d = document.createElement('div'); d.textContent = String(s == null ? '' : s); return d.innerHTML; +} +/** GA list_llms 形如 SessionClass/备注;桌面 UI 只展示 / 后一段 */ +function profileLabel(name) { + const s = String(name || ''); + const i = s.indexOf('/'); + return (i >= 0 ? s.slice(i + 1) : s).trim(); +} +function normalizeProfiles(list) { + return (list || []).map(p => ({ ...p, name: profileLabel(p.name) || p.name })); +} +function sanitizeMarkdown(html) { + const tpl = document.createElement('template'); + tpl.innerHTML = String(html); + const blocked = new Set(['SCRIPT','STYLE','IFRAME','OBJECT','EMBED','LINK','META','BASE','FORM','INPUT','BUTTON']); + const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_ELEMENT); + const rmv = []; + while (walker.nextNode()) { + const el = walker.currentNode; + if (blocked.has(el.tagName)) { rmv.push(el); continue; } + for (const attr of Array.from(el.attributes)) { + const n = attr.name.toLowerCase(), v = attr.value.trim(); + if (n.startsWith('on') || n === 'srcdoc') { el.removeAttribute(attr.name); continue; } + if ((n === 'href' || n === 'src' || n === 'xlink:href') && v && !ALLOWED_URI_RE.test(v)) el.removeAttribute(attr.name); + } + if (el.tagName === 'A') { el.setAttribute('rel','noopener noreferrer'); el.setAttribute('target','_blank'); } + } + rmv.forEach(el => el.remove()); + return tpl.innerHTML; +} +/* ═══════════════ LaTeX 保护 (PR移植) ═══════════════ */ +const _latexSlots = []; +function protectLatex(text) { + _latexSlots.length = 0; + // 先保护代码围栏和行内代码,避免其中的 $ \( \[ 被误匹配 + const _codeSlots = []; + // 代码围栏 ```...``` + text = text.replace(/```[\s\S]*?```/g, (m) => { + const id = _codeSlots.length; + _codeSlots.push(m); + return `\x00CODE:${id}\x00`; + }); + // 行内代码 `...` + text = text.replace(/`[^`\n]+`/g, (m) => { + const id = _codeSlots.length; + _codeSlots.push(m); + return `\x00CODE:${id}\x00`; + }); + // 块级 \[...\] + text = text.replace(/\\\[([\s\S]+?)\\\]/g, (_, expr) => { + const id = _latexSlots.length; + _latexSlots.push({ expr: expr.trim(), display: true }); + return ``; + }); + // 块级 $$...$$ + text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_, expr) => { + const id = _latexSlots.length; + _latexSlots.push({ expr: expr.trim(), display: true }); + return ``; + }); + // 行内 \(...\) + text = text.replace(/\\\(([\s\S]+?)\\\)/g, (_, expr) => { + const id = _latexSlots.length; + _latexSlots.push({ expr: expr.trim(), display: false }); + return ``; + }); + // 行内 $...$(不贪婪,排除 $$ 和转义) + text = text.replace(/(? { + const id = _latexSlots.length; + _latexSlots.push({ expr: expr.trim(), display: false }); + return ``; + }); + // 恢复代码占位符 + text = text.replace(/\x00CODE:(\d+)\x00/g, (_, i) => _codeSlots[Number(i)]); + return text; +} +function restoreLatex(html) { + if (!_latexSlots.length) return html; + return html.replace(//g, (_, i) => { + const slot = _latexSlots[Number(i)]; + if (!slot) return ''; + if (typeof katex === 'undefined') { + return slot.display ? `
${escapeHtml(slot.expr)}
` + : `${escapeHtml(slot.expr)}`; + } + try { + const rendered = katex.renderToString(slot.expr, { displayMode: slot.display, throwOnError: false }); + return slot.display ? `
${rendered}
` + : `${rendered}`; + } catch (_) { return escapeHtml(slot.expr); } + }); +} + +function renderMarkdown(text) { + if (typeof marked === 'undefined') return escapeHtml(text).replace(/\n/g, '
'); + try { + const protected_ = protectLatex(String(text || '')); + let html = sanitizeMarkdown(marked.parse(protected_)); + html = restoreLatex(html); + // TUI 风格代码块:包装 pre>code 为 .code-block 容器 + 语言头 + html = html.replace(/
]*>([\s\S]*?)<\/code><\/pre>/g,
+      (_, lang, body) => {
+        const label = lang || 'code';
+        return `
${escapeHtml(label)}
${body}
`; + }); + return html; + } catch (_) { return escapeHtml(text); } +} +/** + * Agent 流协议(与 agent_loop.py / continue_cmd 一致)按行解析: + * - 工具调用:🛠️ 行 + 开围栏行 `` `{n}text `` + 正文 + 闭围栏行(仅 `{n},取区间内最后一行) + * - 工具结果:开围栏行 `` `{n} ``(n≥5)+ 正文 + 同长度闭围栏行 + */ +function parseAgentFenceLine(line) { + const m = /^[ \t]*(`{3,})([^\n`]*)[ \t]*$/.exec(line ?? ''); + if (!m) return null; + return { ticks: m[1].length, tag: m[2] }; +} + +function isAgentStructureBoundaryLine(line, opts) { + if (/^🛠️ Tool:/.test(line)) return true; + // 工具「结果」区内:5 反引号是开/闭围栏,不能当边界(否则闭围栏会被当成下一结构 → 拆出多个空「工具结果」) + if (!opts || !opts.forToolResult) { + const f = parseAgentFenceLine(line); + if (f && f.ticks >= 5 && f.tag === '') return true; + } + if (/^\*\*LLM Running \(Turn \d+\)/.test(line)) return true; + if (/^/i.test(line)) return true; + return false; +} + +function indexOfNextAgentStructureLine(lines, from, opts) { + for (let i = from; i < lines.length; i++) { + if (isAgentStructureBoundaryLine(lines[i], opts)) return i; + } + return lines.length; +} + +function lastFenceCloseLineIndex(lines, from, toExclusive, tickCount) { + let last = -1; + for (let i = from; i < toExclusive; i++) { + const f = parseAgentFenceLine(lines[i]); + if (f && f.ticks === tickCount && f.tag === '') last = i; + } + return last; +} + +function parseToolCallBlock(lines, i) { + const m = /^🛠️ Tool: `([^`]+)`/.exec(lines[i] || ''); + if (!m) return null; + const open = parseAgentFenceLine(lines[i + 1]); + if (!open || open.tag !== 'text') return null; + const bodyStart = i + 2; + const zoneEnd = indexOfNextAgentStructureLine(lines, bodyStart); + const closeIdx = lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks); + if (closeIdx < 0) return null; + return { + name: m[1], + body: lines.slice(bodyStart, closeIdx).join('\n'), + nextLine: closeIdx + 1, + }; +} + +function parseToolResultBlock(lines, i) { + const open = parseAgentFenceLine(lines[i]); + if (!open || open.ticks < 5 || open.tag !== '') return null; + const bodyStart = i + 1; + const zoneEnd = indexOfNextToolResultZoneEnd(lines, bodyStart); + const closeIdx = lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks); + if (closeIdx < 0) return null; + return { + body: lines.slice(bodyStart, closeIdx).join('\n'), + nextLine: closeIdx + 1, + }; +} + +/** 工具结果区 zone:不把 5 反引号围栏行当边界(见 isAgentStructureBoundaryLine) */ +function indexOfNextToolResultZoneEnd(lines, from) { + return indexOfNextAgentStructureLine(lines, from, { forToolResult: true }); +} + +/** 流式未闭合工具调用(对齐 TUI _safe_pos:末尾 in-flight 🛠️ 块) */ +function parseInFlightToolCall(lines, i) { + if (parseToolCallBlock(lines, i)) return null; + const m = /^🛠️ Tool: `([^`]+)`/.exec(lines[i] || ''); + if (!m) return null; + const open = parseAgentFenceLine(lines[i + 1]); + let bodyStart; + let zoneEnd; + if (open && open.tag === 'text') { + bodyStart = i + 2; + zoneEnd = indexOfNextAgentStructureLine(lines, bodyStart); + if (lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks) >= 0) return null; + } else { + bodyStart = i + 1; + zoneEnd = lines.length; + for (let j = i + 1; j < lines.length; j++) { + if (isAgentStructureBoundaryLine(lines[j])) { zoneEnd = j; break; } + } + } + return { + name: m[1], + body: lines.slice(bodyStart, zoneEnd).join('\n'), + nextLine: zoneEnd, + inFlight: true, + }; +} + +/** 流式未闭合工具结果(5 反引号围栏未到) */ +function parseInFlightToolResult(lines, i) { + if (parseToolResultBlock(lines, i)) return null; + const open = parseAgentFenceLine(lines[i]); + if (!open || open.ticks < 5 || open.tag !== '') return null; + const bodyStart = i + 1; + const zoneEnd = indexOfNextToolResultZoneEnd(lines, bodyStart); + if (lastFenceCloseLineIndex(lines, bodyStart, zoneEnd, open.ticks) >= 0) return null; + return { + body: lines.slice(bodyStart, zoneEnd).join('\n'), + nextLine: zoneEnd, + inFlight: true, + }; +} + +/** 将 agent 协议块替换为占位符,其余行原样保留给 Markdown */ +function foldAgentProtocolBlocks(body, { onTool, onResult }) { + const lines = String(body || '').split('\n'); + const out = []; + let proseFrom = 0; + let i = 0; + + const flushProse = (until) => { + if (until <= proseFrom) return; + out.push(lines.slice(proseFrom, until).join('\n')); + proseFrom = until; + }; + + while (i < lines.length) { + const tool = parseToolCallBlock(lines, i); + if (tool) { + flushProse(i); + out.push(onTool(tool.name, tool.body)); + i = tool.nextLine; + proseFrom = i; + continue; + } + const result = parseToolResultBlock(lines, i); + if (result) { + flushProse(i); + out.push(onResult(result.body)); + i = result.nextLine; + proseFrom = i; + continue; + } + const liveTool = parseInFlightToolCall(lines, i); + if (liveTool) { + flushProse(i); + out.push(onTool(liveTool.name, liveTool.body, { inFlight: true })); + i = liveTool.nextLine; + proseFrom = i; + continue; + } + const liveResult = parseInFlightToolResult(lines, i); + if (liveResult) { + flushProse(i); + out.push(onResult(liveResult.body, { inFlight: true })); + i = liveResult.nextLine; + proseFrom = i; + continue; + } + i++; + } + flushProse(lines.length); + return out.join(''); +} + +function extractAskUserToolJson(content) { + const lines = String(content || '').split('\n'); + for (let i = 0; i < lines.length; i++) { + const block = parseToolCallBlock(lines, i); + if (block && block.name === 'ask_user') return block.body; + } + return null; +} + +// ============================================================================ +// [turn_segs 数据结构层] —— 单轮渲染的纯函数,供 append-only draft 与静态消息复用 +// 设计:每个函数自包含(内部自建 folds/asks 占位栈,渲染完即还原),轮间零共享状态。 +// renderTurnBody(body) : 单轮原文 → 该轮内层HTML(块级折叠thinking/tool/result) +// renderTurnFold(body, turnIndex) : 单轮原文 → 旧轮的
折叠壳(内部下标0起,标题显示1起+summary副标题) +// 结构化 turn_segs 渲染使用的纯函数:折叠工具块、ask_user 与轮摘要。 +// ============================================================================ +// 去除 GenericAgent 轮次分隔标记;turn_segs 已结构化,不应展示原始 marker +function stripTurnMarker(body) { + return String(body || '') + .replace(/^\s*\**LLM Running \(Turn \d+\) \.\.\.\**\s*/i, ''); +} + +function renderTurnBody(body) { + // 自包含:每次调用独立的占位栈,渲染完立即还原,无跨调用共享状态 + const folds = []; + const asks = []; + const stash = (label, b, cls, opts) => { + folds.push({ label, body: b, cls: cls || '', open: !!(opts && opts.open) }); + return `\n\n§§FOLD:${folds.length - 1}§§\n\n`; + }; + const stashAsk = (data) => { asks.push(data); return `\n\n§§ASK:${asks.length - 1}§§\n\n`; }; + let s = stripTurnMarker(body); + s = s.replace(/[\s\S]*?<\/thinking>/gi, m => stash(t('fold.thinking'), m.replace(/<\/?thinking>/gi, ''), 'fold-thinking')); + s = foldAgentProtocolBlocks(s, { + onTool(name, json, meta) { + if (name === 'ask_user' && !meta?.inFlight) { + const data = parseAskUserJson(json); + if (data && normalizeAskUserData(data)) return stashAsk(data); + } + const live = !!meta?.inFlight; + return stash(`${t('fold.tool')}: ${name}${live ? ' …' : ''}`, json, + live ? 'fold-tool fold-tool-live' : 'fold-tool', { open: live }); + }, + onResult(b, meta) { + const live = !!meta?.inFlight; + return stash(`${t('fold.toolResult')}${live ? ' …' : ''}`, b, + live ? 'fold-result fold-tool-live' : 'fold-result', { open: live }); + }, + }); + s = s.replace(/[\s\S]*?<\/function_calls>/gi, m => stash(t('fold.tool'), m, 'fold-tool')); + s = s.replace(/[\s\S]*?<\/function_results>/gi, m => stash(t('fold.toolResult'), m, 'fold-result')); + s = s.replace(/([\s\S]*?)<\/summary>/gi, (_, inner) => `
${inner}
`); + let html = renderMarkdown(s); + // 还原占位符 + html = html + .replace(/§§ASK:(\d+)§§/g, (_, i) => { + const data = asks[Number(i)]; + return data ? renderAskUserNotice(data) : ''; + }) + .replace(/§§FOLD:(\d+)§§/g, (_, i) => { + const f = folds[Number(i)]; + if (!f) return ''; + const openAttr = f.open ? ' open' : ''; + return `
${escapeHtml(f.label)}
${escapeHtml(f.body)}
`; + }); + return html; +} + +// 抽出该轮首个 文本作为折叠头副标题;无则回退提取工具名列表 +function extractTurnSummaryPure(raw) { + const m = /([\s\S]*?)<\/summary>/i.exec(raw || ''); + if (m) return m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + const tools = []; + const toolRe = /🛠️\s*Tool:\s*`([^`]+)`/g; + let tm; + while ((tm = toolRe.exec(raw || '')) !== null) { + if (!tools.includes(tm[1])) tools.push(tm[1]); + } + return tools.length ? tools.join(', ') : ''; +} + +// 旧轮折叠壳:内部 turnIndex 从 0 起;UI 标题显示为 1 起。调用方一律传内部下标。 +function turnDisplayNo(turnIndex) { + return Math.max(0, Number(turnIndex) || 0) + 1; +} +function renderTurnFold(body, turnIndex) { + const raw = stripTurnMarker(body); + const sum = extractTurnSummaryPure(raw); + const bodyForRender = sum ? raw.replace(/[\s\S]*?<\/summary>\s*/i, '') : raw; + const inner = renderTurnBody(bodyForRender); + const turnLabel = t('fold.turn').replace('{n}', turnDisplayNo(turnIndex)); + const head = sum + ? `${escapeHtml(turnLabel)}:${escapeHtml(sum)}` + : escapeHtml(turnLabel); + return `
${head}${inner}
`; +} + +function lastInflightBlockBody(body) { + const lines = String(body || '').split('\n'); + let last = null; + for (let i = 0; i < lines.length; i++) { + const liveTool = parseInFlightToolCall(lines, i); + if (liveTool?.inFlight) { last = { body: liveTool.body }; i = liveTool.nextLine - 1; continue; } + const closedTool = parseToolCallBlock(lines, i); + if (closedTool) { last = null; i = closedTool.nextLine - 1; continue; } + const liveRes = parseInFlightToolResult(lines, i); + if (liveRes?.inFlight) { last = { body: liveRes.body }; i = liveRes.nextLine - 1; continue; } + const closedRes = parseToolResultBlock(lines, i); + if (closedRes) { last = null; i = closedRes.nextLine - 1; continue; } + } + return last; +} + +function tryPatchInflightToolDom(curEl, body, prevBody) { + if (!prevBody || body.length < prevBody.length || !body.startsWith(prevBody)) return false; + if (!curEl.querySelector('details.fold-tool-live')) return false; + const prevBlock = lastInflightBlockBody(prevBody); + const curBlock = lastInflightBlockBody(body); + if (!curBlock || !prevBlock || !curBlock.body.startsWith(prevBlock.body)) return false; + const liveFolds = curEl.querySelectorAll('details.fold-tool-live'); + const pre = liveFolds[liveFolds.length - 1]?.querySelector('.fold-pre'); + if (!pre) return false; + pre.textContent = curBlock.body; + return true; +} + +function parseAskUserJson(raw) { + if (raw == null) return null; + const txt = String(raw).trim(); + if (!txt) return null; + try { return JSON.parse(txt); } catch (_) {} + try { + let out = ''; + let inStr = false; + let esc = false; + for (let i = 0; i < txt.length; i++) { + const c = txt[i]; + if (esc) { out += c; esc = false; continue; } + if (c === '\\') { out += c; esc = true; continue; } + if (c === '"') { inStr = !inStr; out += c; continue; } + if (inStr) { + if (c === '\n') out += '\\n'; + else if (c === '\r') out += '\\r'; + else if (c === '\t') out += '\\t'; + else if (c.charCodeAt(0) < 0x20) out += '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'); + else out += c; + } else out += c; + } + return JSON.parse(out); + } catch (_) {} + return null; +} + +function normalizeAskUserData(data) { + const raw = data || {}; + const question = String(raw.question || '').trim(); + if (!question) return null; + const cs = raw.candidates || []; + const candidates = Array.isArray(cs) + ? cs.map(x => String(x == null ? '' : x)).filter(x => x.trim()) + : []; + return { question, candidates }; +} + +/** 格式化 ask_user 题干:编号与正文同行;无空行时在 2./3. 前分段 */ +function formatAskUserQuestion(text) { + let s = String(text || '').trim(); + if (!s) return s; + // 「1.\n正文」→「1. 正文」 + s = s.replace(/^(\d+[.、::)])\s*\n+\s*/gm, '$1 '); + s = s.replace(/(\n)(\d+[.、::)])\s*\n+\s*/g, '$1$2 '); + s = s.replace(/(\n|^)(问题\s*\d+\s*[::.、)]?)\s*\n+\s*/gi, '$1$2 '); + // 题与题之间:尚无空行时,仅在 2./3. 前插入空行(不动 1. 与题干) + if (!/\n\s*\n/.test(s)) { + s = s.replace(/(\S)\s+(?=问题\s*[2-9]\d*\s*[::.、)]?\s*)/gi, '$1\n\n'); + s = s.replace(/(\S)\s+(?=[2-9]\d*[.、::)]\s+\S)/g, '$1\n\n'); + } + return boldAskQuestionLines(s); +} + +function boldAskQuestionLines(text) { + return String(text || '').split('\n').map(line => { + const t = line.trim(); + if (!t || /^\*\*.+\*\*$/.test(t)) return line; + if (/^\d+[.、::)]\s+\S/.test(t)) return '**' + t + '**'; + if (/^问题\s*\d+/i.test(t)) return '**' + t + '**'; + if (/[??]\s*$/.test(t) && !/^[A-Da-d][.)]\s/.test(t)) return '**' + t + '**'; + return line; + }).join('\n'); +} + +function markAskOptionHtml(html) { + let out = String(html || ''); + out = out.replace(/

([^<]*[A-Da-d][.)]\s[^<]*)<\/p>/gi, '

$1

'); + out = out.replace(/()\s*([A-Da-d][.)]\s[^<]+)/gi, '$2'); + return out; +} + +/** 预览模式:true = 始终显示 candidates;false = 题干已含选项/多题时不重复渲染底部列表 */ +const ASK_USER_ALWAYS_SHOW_CANDIDATES = false; + +/** 题干已含选项/多题,或 candidates 无法与题干对应时,不再重复渲染底部列表 */ +function shouldShowAskCandidates(item) { + if (!item || !item.candidates.length) return false; + if (ASK_USER_ALWAYS_SHOW_CANDIDATES) return true; + const q = item.question; + if (/两个问题|多个问题|两道|两题/.test(q)) return false; + if ((q.match(/问题\s*\d/gi) || []).length >= 2) return false; + if ((q.match(/^[ \t]*\d+[.、::)]\s+/gm) || []).length >= 2) return false; + if ((q.match(/^[ \t]*[A-Da-d][.)]\s/mg) || []).length >= 2) return false; + const comboN = item.candidates.filter(c => /\d+[A-Da-d]\s*\+\s*\d+[A-Da-d]/i.test(c)).length; + if (comboN >= Math.max(1, Math.ceil(item.candidates.length * 0.5))) return false; + // 题干里有多道问句,却把全部选项平铺在 candidates → 无法区分归属,不展示 + const qMarks = (q.match(/[??]/g) || []).length; + if (qMarks >= 2 && item.candidates.length > 4) return false; + return true; +} + + +function renderAskUserNotice(data) { + const item = normalizeAskUserData(data); + if (!item) return ''; + // 单题与多题统一处理:多题的选项本就内联在题干里;单题的选项放在 candidates 里, + // 这里把它折叠进题干,按同样的 A./B./C. 内联方式渲染,不再单独画一个编号列表。 + const question = foldAskCandidates(item); + const qHtml = markAskOptionHtml(renderMarkdown(formatAskUserQuestion(question))); + return `
+
+ ${escapeHtml(t('ask.banner'))} + + ${escapeHtml(t('ask.replyHint'))} +
+ ${qHtml ? `
${qHtml}
` : ''} +
`; +} + +/** 单题的 candidates 折叠进题干(统一成 A./B./C. 内联选项);多题或无法对应时原样返回题干 */ +function foldAskCandidates(item) { + if (!shouldShowAskCandidates(item)) return item.question; + const opts = item.candidates.map((c, j) => { + const label = String(c).replace(/^\s*(?:[A-Za-z]|\d{1,2})\s*[.)、::]\s*/, '').trim(); + return `${String.fromCharCode(65 + j)}. ${label}`; + }).join('\n'); + // 用单换行(而非空行)拼进题干,让题干+选项渲染成同一个

,每个选项都跟在
后面 — + // 与多题内联选项走完全一致的 .ask-option-line 缩进,避免首项 A 贴左边、B/C/D 缩进的错位。 + return item.question.replace(/\s+$/, '') + '\n' + opts; +} + +function askUserPlaceholder(item) { + // 单题与多题统一:都用自由作答提示,不再针对单题单独显示「输入 1/2/3 选择」 + return t('ask.placeholderOpen'); +} + +function assistantStructuredText(msg) { + if (!msg || msg.role !== 'assistant') return ''; + if (Array.isArray(msg.turn_segs) && msg.turn_segs.length) return msg.turn_segs.join('\n'); + return typeof msg.content === 'string' ? msg.content : ''; +} + +function getPendingAskUser(sess) { + if (!sess || rt(sess).busy) return null; + const msgs = sess.messages || []; + let lastAskIdx = -1; + let askData = null; + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role !== 'assistant') continue; + const json = extractAskUserToolJson(assistantStructuredText(msgs[i])); + if (json != null) { + lastAskIdx = i; + askData = normalizeAskUserData(parseAskUserJson(json)); + break; + } + } + if (!askData) return null; + const replied = msgs.slice(lastAskIdx + 1).some(m => m.role === 'user'); + return replied ? null : askData; +} + +function syncAskUserUi() { + const sess = activeSess(); + const pending = sess ? getPendingAskUser(sess) : null; + const notices = [...document.querySelectorAll('.ask-user-notice')]; + notices.forEach((el, i) => { + const isLast = i === notices.length - 1; + el.classList.toggle('is-active', !!pending && isLast); + el.classList.toggle('is-answered', !pending || !isLast); + }); + if (inputEl) inputEl.setAttribute('data-ph', pending ? askUserPlaceholder(pending) : t('composer.placeholder')); // contenteditable 用 data-ph(无 placeholder 属性) + if (composerEl) composerEl.classList.toggle('is-awaiting-answer', !!pending); +} + +/* ═══════════════ 渲染后增强 (PR移植) ═══════════════ */ +/* ───────────── 统一复制 SVG Icon ───────────── */ +// Phosphor 图标助手:把 window.gaIcon(name) 包一层,给动态渲染的 UI 用,与静态 [data-ga-icon] 保持一致 +const GA_ICON = (name, className = '') => (typeof window.gaIcon === 'function' ? window.gaIcon(name, className) : ''); +const SVG_COPY_ICON = GA_ICON('copy'); +const SVG_CHECK_ICON = GA_ICON('check'); + +function postRenderEnhance(containerEl) { + if (!containerEl) return; + // 代码高亮 + 复制按钮(.code-block 容器已自带头部复制按钮,跳过) + containerEl.querySelectorAll('pre code').forEach(block => { + if (typeof hljs !== 'undefined') hljs.highlightElement(block); + if (block.closest('.code-block')) return; // TUI 风格容器已有复制按钮 + if (!block.parentElement.querySelector('.code-copy-btn')) { + const btn = document.createElement('button'); + btn.className = 'code-copy-btn'; btn.innerHTML = SVG_COPY_ICON; + btn.title = t('act.copy'); + btn.onclick = () => { + navigator.clipboard.writeText(block.textContent).then(() => { + btn.innerHTML = SVG_CHECK_ICON; setTimeout(() => btn.innerHTML = SVG_COPY_ICON, 1500); + }); + }; + block.parentElement.style.position = 'relative'; + block.parentElement.appendChild(btn); + } + }); + // TUI 代码块头部复制按钮绑定 + containerEl.querySelectorAll('.code-block-copy').forEach(btn => { + if (btn.dataset.bound) return; + btn.dataset.bound = '1'; + btn.onclick = () => { + const code = btn.closest('.code-block').querySelector('code'); + if (!code) return; + navigator.clipboard.writeText(code.textContent.trim()).then(() => { + btn.textContent = '\u2713'; + setTimeout(() => { btn.textContent = '\u29C9'; }, 1500); + }); + }; + }); + // KaTeX 复制按钮 + containerEl.querySelectorAll('.katex-block').forEach(el => { + if (el.querySelector('.latex-copy-btn')) return; + const src = el.querySelector('annotation[encoding="application/x-tex"]'); + if (!src) return; + const btn = document.createElement('button'); + btn.className = 'latex-copy-btn'; btn.textContent = '\u29C9'; + btn.title = t('act.copyTex'); + btn.onclick = () => { + navigator.clipboard.writeText(src.textContent).then(() => { + btn.textContent = '\u2713'; setTimeout(() => btn.textContent = '\u29C9', 1500); + }); + }; + el.style.position = 'relative'; + el.appendChild(btn); + }); + syncAskUserUi(); +} + + +/* ═══════════════ 状态 ═══════════════ */ +const state = { + sessions: new Map(), activeId: null, bridgeReady: false, + llmNo: 0, modelProfiles: [], modelName: null, + runtime: new Map(), + pendingFiles: [], + fileSeq: 0, +}; +function rt(sess) { + let r = state.runtime.get(sess.id); + if (!r) { r = { polling:false, busy:false, lastId:0, seen:new Set(), draftEl:null, draftSegs:null, draftTurn:0, taskStartedAt:null, taskEndedAt:null, taskTimerId:null, planCompleteAt:null, planLostAt:null, planHoldItems:[], planLastPayload:null, planLastComplete:false, planHideTimer:null, planDismissedComplete:false, planCollapsed:false, planShowAll:false }; state.runtime.set(sess.id, r); } + return r; +} +const activeSess = () => state.sessions.get(state.activeId) || null; +const isActive = (sess) => sess && sess.id === state.activeId; + +function saveSessions() {} +function patchSession(sess, fields) { + if (!sess.bridgeSessionId) return; + fetch(`${BRIDGE_ORIGIN}/session/${encodeURIComponent(sess.bridgeSessionId)}`, { + method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify(fields) + }).catch(() => {}); +} +async function loadSessions() { + try { + const res = await fetch(`${BRIDGE_ORIGIN}/sessions`); + const data = await res.json(); + if (!data.sessions) return; + for (const s of data.sessions) { + state.sessions.set(s.id, { + id: s.id, bridgeSessionId: s.id, title: s.title, + messages: [], untitled: s.untitled ?? true, + pinned: s.pinned ?? false, lastActiveTs: s.updatedAt || s.createdAt + }); + } + // 刷新后固定恢复「上次正在看的会话」(前端持久化的 ga_active),而不是 bridge 的 + // activeSessionId(=最近更新的会话,会随后台会话变动而跳来跳去)。没有有效的已存 + // 会话则置空 → 显示「新会话」空态,由用户自己点选。 + const savedActive = localStorage.getItem('ga_active'); + state.activeId = (savedActive && state.sessions.has(savedActive)) ? savedActive : null; + } catch (_) {} +} + +/* ═══════════════ DOM refs ═══════════════ */ +const chatPage = document.querySelector('.page[data-page="chat"]'); +const msgArea = chatPage.querySelector('.msg-area'); +const chatStart = msgArea.querySelector('.chat-start'); +const inputEl = document.getElementById('chat-input'); +const sendBtn = document.getElementById('send-btn'); +const planBarEl = document.getElementById('plan-bar'); +const composerEl = document.getElementById('chat-composer'); +const msgLoading = document.getElementById('msg-loading'); +const sessionLoadingEl = document.getElementById('session-loading'); +const MIN_MSG_LOADING_MS = 450; +const HYDRATE_LOADING_TIMEOUT_MS = 10000; +const POLL_MSG_LIMIT = 200; +const PLAN_LOST_GRACE_MS = 1500; // tuiapp_v2._PLAN_LOST_GRACE_SEC +const PLAN_COMPLETE_GRACE_MS = 3000; // tuiapp_v2._PLAN_GRACE_SEC + +function isPlanPresetPrompt(text) { + const p = String(text || '').toLowerCase(); + return p.includes('plan_sop') || p.includes('plan 模式') || p.includes('plan mode'); +} +let _submitInFlight = false; +const runToggle = document.getElementById('run-toggle'); +const chatStatus = pageStatusBar(runToggle); +const runLabel = runToggle?.querySelector('.rs-label'); +const convListEl = document.querySelector('.conv-list'); +const newConvBtn = document.querySelector('.new-conv'); +const searchInput = document.querySelector('.search input'); +const rpResize = document.getElementById('rp-resize'); +const rpPanel = document.getElementById('rightpanel'); +const bodyEl = document.querySelector('.body'); +/* 每个页面的 page-top 各自挂一对 hamburger / 会话 按钮(.pt-sb-toggle / .pt-rp-toggle), + 全部绑同一个 toggle,效果跟以前的单一 sb-toggle/rp-toggle 一样,只是入口变成顶栏。 */ +document.querySelectorAll('.pt-sb-toggle').forEach(b => b.addEventListener('click', () => bodyEl.classList.toggle('sb-collapsed'))); +document.querySelectorAll('.pt-rp-toggle').forEach(b => b.addEventListener('click', () => bodyEl.classList.toggle('rp-collapsed'))); + +const sbResize = document.getElementById('sb-resize'); +const sbPanel = document.querySelector('.sidebar'); + +// 通用拖拽:dir=+1 拖动 →clientX 增大就增宽(左侧栏);dir=-1 反之(右侧) +function bindResize(handle, panel, dir, min, max) { + if (!handle || !panel) return; + let dragging = false, startX = 0, startW = 0; + handle.addEventListener('mousedown', (e) => { + dragging = true; startX = e.clientX; startW = panel.offsetWidth; + handle.classList.add('dragging'); + panel.style.transition = 'none'; // 拖拽期间禁用 transition,避免宽度动画延迟 + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + document.addEventListener('mousemove', (e) => { + if (!dragging) return; + const w = Math.min(max, Math.max(min, startW + dir * (e.clientX - startX))); + panel.style.width = w + 'px'; + panel.style.flex = '0 0 ' + w + 'px'; + }); + document.addEventListener('mouseup', () => { + if (!dragging) return; + dragging = false; + handle.classList.remove('dragging'); + panel.style.transition = ''; // 恢复 CSS transition(按钮折叠动画仍生效) + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }); +} +bindResize(rpResize, rpPanel, -1, 160, 400); // 右栏:cursor 左移 → 增宽 +bindResize(sbResize, sbPanel, +1, 180, 360); // 左栏:cursor 右移 → 增宽 +const modelChip = document.getElementById('model-chip'); +const modelNameEl= modelChip ? modelChip.querySelector('.model-name') : null; +// conductor 页面也有一个独立的模型 chip,共用一份模型数据 +const collabModelChip = document.getElementById('cdb-model-chip'); +const collabModelNameEl = collabModelChip ? collabModelChip.querySelector('.model-name') : null; + +let msgsEl = null; +function ensureMsgs() { + if (!msgsEl) { + msgsEl = document.createElement('div'); + msgsEl.className = 'msgs'; + msgArea.insertBefore(msgsEl, msgLoading || null); + } + return msgsEl; +} +function refreshEmptyState(sess) { + const has = sess && sess.messages.length > 0; + msgArea.classList.toggle('has-msgs', !!has); + if (chatStart) chatStart.style.display = has ? 'none' : ''; + if (msgsEl) msgsEl.style.display = has ? '' : 'none'; +} + +function planTpl(tpl, v) { + return String(tpl || '').replace(/\{(\w+)\}/g, (_, k) => (v[k] != null ? String(v[k]) : `{${k}}`)); +} + +let planPollTimer; +function syncPlanPollTimer() { + const on = !!(activeSess()?.bridgeSessionId && state.bridgeReady); + if (on && !planPollTimer) { + planPollTimer = setInterval(() => { + const s = activeSess(); + if (!s || !isActive(s)) return; + planFetch(s); + planTick(s); + }, 1000); + } else if (!on && planPollTimer) { + clearInterval(planPollTimer); + planPollTimer = null; + } +} + +function clearPlanGrace(r) { + r.planCompleteAt = r.planLostAt = null; + r.planHoldItems = []; + r.planLastPayload = null; + r.planLastComplete = false; + r.planDismissedComplete = false; + if (r.planHideTimer) { clearTimeout(r.planHideTimer); r.planHideTimer = null; } +} + +function schedulePlanCompleteDismiss(sess) { + const r = rt(sess); + if (r.planHideTimer) clearTimeout(r.planHideTimer); + r.planHideTimer = setTimeout(() => { + r.planHideTimer = null; + r.planDismissedComplete = true; + if (isActive(sess)) refreshPlanBar(null); + }, PLAN_COMPLETE_GRACE_MS); +} + +/** tuiapp_v2._refresh_planbar:用 runtime 里缓存的 items / placeholder 重绘 */ +function refreshPlanBarFromRuntime(sess) { + const r = rt(sess); + const lp = r.planLastPayload; + let items = r.planHoldItems || []; + if (r.planLostAt != null && Date.now() - r.planLostAt >= PLAN_LOST_GRACE_MS) { + items = []; + r.planHoldItems = []; + r.planLostAt = null; + } + if (r.planDismissedComplete) { + refreshPlanBar(null); + return; + } + if (r.planCompleteAt != null && Date.now() - r.planCompleteAt >= PLAN_COMPLETE_GRACE_MS) { + r.planDismissedComplete = true; + refreshPlanBar(null); + return; + } + if (!items.length) { + if (lp?.active && lp.placeholder) { + refreshPlanBar(lp); + return; + } + const held = r.planHoldItems || []; + if (lp?.complete && (lp.items?.length || held.length)) { + refreshPlanBar({ + active: true, + placeholder: false, + items: lp.items?.length ? lp.items : held, + done: lp.done ?? held.filter(it => it.status === 'done').length, + total: lp.total ?? (lp.items?.length || held.length), + complete: true, + step: lp.step || '', + }); + return; + } + refreshPlanBar(null); + return; + } + refreshPlanBar({ + active: true, + placeholder: false, + items, + done: lp?.done ?? items.filter(it => it.status === 'done').length, + total: lp?.total ?? items.length, + complete: !!(lp?.complete || (items.length && items.every(it => it.status === 'done'))), + step: lp?.step || '', + }); +} + +/** 每秒 tick grace(对齐 TUI _poll_plan_files → _refresh_planbar) */ +function planTick(sess) { + if (!sess || !isActive(sess)) return; + refreshPlanBarFromRuntime(sess); +} + +function applyPlanPayload(sess, raw) { + if (!sess) return; + const r = rt(sess); + const now = Date.now(); + + if (raw?.active) { + if (raw.placeholder && !r.planLastPayload?.active) { + r.planCollapsed = false; + r.planShowAll = false; + } + r.planLastPayload = raw; + const items = raw.items || []; + if (items.length) { + r.planLostAt = null; + r.planHoldItems = items; + } else if (!raw.placeholder && !raw.complete && r.planHoldItems.length) { + if (!r.planLostAt) r.planLostAt = now; + } + const nowComplete = !!raw.complete && (items.length > 0 || r.planHoldItems.length > 0); + const wasComplete = r.planLastComplete; + if (nowComplete && !wasComplete) { + r.planCompleteAt = now; + schedulePlanCompleteDismiss(sess); + } else if (!nowComplete) { + r.planCompleteAt = null; + r.planDismissedComplete = false; + if (r.planHideTimer) { clearTimeout(r.planHideTimer); r.planHideTimer = null; } + } + r.planLastComplete = nowComplete; + } else if (r.planHoldItems.length && !r.planDismissedComplete) { + if (!r.planLostAt) r.planLostAt = now; + } else if (!r.planDismissedComplete) { + clearPlanGrace(r); + } + + if (!isActive(sess)) return; + if (r.planDismissedComplete) { + refreshPlanBar(null); + return; + } + if (raw?.active && raw.placeholder) { + refreshPlanBar(raw); + return; + } + if (raw?.active && raw.complete && (raw.items?.length || r.planHoldItems.length)) { + refreshPlanBar({ + ...raw, + items: raw.items?.length ? raw.items : r.planHoldItems, + }); + return; + } + refreshPlanBarFromRuntime(sess); +} + +function planItemUi(status, isCurrent) { + const st = String(status || 'open').toLowerCase(); + if (st === 'done') return { cls: 'plan-item--done', mark: '✓' }; + if (st === 'error' || st === 'failed') return { cls: 'plan-item--error', mark: '✕' }; + if (st === 'warn' || st === 'warning') return { cls: 'plan-item--warn', mark: '!' }; + if (isCurrent) return { cls: 'plan-item--current', mark: '●' }; + return { cls: 'plan-item--pending', mark: '○' }; +} + +function pickPlanWindow(items, stepText) { + const list = Array.isArray(items) ? items : []; + if (!list.length) return { shown: [], curInShown: -1, overflow: 0 }; + let cur = list.findIndex(it => it.status !== 'done'); + if (cur < 0) cur = list.length - 1; + const step = String(stepText || '').trim(); + if (step) { + const hit = list.findIndex(it => String(it.content || '').includes(step.slice(0, 24))); + if (hit >= 0) cur = hit; + } + let start, end; + if (cur <= 1) { + start = cur; + end = Math.min(list.length, cur + 3); + } else { + start = cur - 1; + end = Math.min(list.length, cur + 2); + } + const shown = list.slice(start, end); + return { shown, curInShown: cur - start, overflow: Math.max(0, list.length - shown.length) }; +} + +function planCapsuleLabel(plan) { + const step = plan.step ? String(plan.step).slice(0, 80) : ''; + if (plan.complete) return { tag: t('plan.capsuleComplete'), step: step || planTpl(t('plan.complete'), { n: plan.total }) }; + if (plan.placeholder) return { tag: t('plan.placeholder'), step: '' }; + return { tag: t('plan.capsuleRunning'), step: step || planTpl(t('plan.header'), { done: plan.done, total: plan.total }) }; +} + +function bindPlanCardUiOnce() { + if (!planBarEl || planBarEl._planUiBound) return; + planBarEl._planUiBound = true; + planBarEl.addEventListener('click', (e) => { + const sess = activeSess(); + if (!sess) return; + const r = rt(sess); + const payload = r.planLastPayload; + if (!payload?.active) return; + if (e.target.closest('[data-plan-expand]')) { + r.planCollapsed = false; + refreshPlanBar(payload); + } else if (e.target.closest('[data-plan-collapse]')) { + r.planCollapsed = true; + refreshPlanBar(payload); + } else if (e.target.closest('[data-plan-details]')) { + r.planShowAll = !r.planShowAll; + refreshPlanBar(payload); + } + }); +} + +function refreshPlanBar(plan) { + if (!planBarEl) return; + bindPlanCardUiOnce(); + if (!plan?.active) { + planBarEl.hidden = true; + planBarEl.replaceChildren(); + planBarEl.className = 'plan-card'; + return; + } + const sess = activeSess(); + const r = sess ? rt(sess) : { planCollapsed: false, planShowAll: false }; + const collapsed = !!r.planCollapsed; + const stepText = plan.step ? String(plan.step).slice(0, 120) : ''; + const done = plan.done ?? (plan.items || []).filter(it => it.status === 'done').length; + const total = plan.total ?? (plan.items || []).length; + const mod = [ + 'plan-card', + collapsed ? 'plan-card--collapsed' : 'plan-card--expanded', + plan.complete ? 'plan-card--complete' : '', + plan.placeholder ? 'plan-card--placeholder' : '', + ].filter(Boolean).join(' '); + planBarEl.hidden = false; + planBarEl.className = mod; + + if (collapsed) { + const cap = planCapsuleLabel(plan); + planBarEl.innerHTML = ''; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'plan-capsule'; + btn.dataset.planExpand = '1'; + const dot = document.createElement('span'); + dot.className = 'plan-status-dot'; + const txt = document.createElement('span'); + txt.className = 'plan-capsule-text'; + if (cap.step) txt.innerHTML = `${escapeHtml(cap.tag)} · ${escapeHtml(cap.step)}`; + else txt.textContent = cap.tag; + btn.append(dot, txt); + planBarEl.append(btn); + return; + } + + const frag = document.createDocumentFragment(); + const head = document.createElement('div'); + head.className = 'plan-card-head'; + const dot = document.createElement('span'); + dot.className = 'plan-status-dot'; + const title = document.createElement('span'); + title.className = 'plan-title'; + title.textContent = plan.placeholder ? t('plan.placeholder') + : plan.complete ? t('plan.completeTitle') + : t('plan.running'); + head.append(dot, title); + if (!plan.placeholder && total > 0) { + const prog = document.createElement('span'); + prog.className = 'plan-progress'; + prog.textContent = `${done}/${total}`; + head.append(prog); + } + const actions = document.createElement('div'); + actions.className = 'plan-head-actions'; + const collapseBtn = document.createElement('button'); + collapseBtn.type = 'button'; + collapseBtn.className = 'plan-btn'; + collapseBtn.dataset.planCollapse = '1'; + collapseBtn.textContent = t('plan.collapse'); + actions.append(collapseBtn); + head.append(actions); + frag.append(head); + + if (stepText) { + const cur = document.createElement('div'); + cur.className = 'plan-current'; + const lab = document.createElement('span'); + lab.className = 'plan-current-label'; + lab.textContent = `${t('plan.current')}:`; + const body = document.createElement('span'); + body.className = 'plan-current-text'; + body.textContent = stepText; + cur.append(lab, body); + frag.append(cur); + } + + if (plan.placeholder) { + const wait = document.createElement('div'); + wait.className = 'plan-wait'; + wait.textContent = planTpl(t('plan.waiting'), { path: plan.pathHint || 'plan.md' }); + frag.append(wait); + } else { + const list = plan.items || []; + const { shown, curInShown, overflow } = r.planShowAll + ? { shown: list, curInShown: list.findIndex(it => it.status !== 'done'), overflow: 0 } + : pickPlanWindow(list, stepText); + if (shown.length) { + const ul = document.createElement('ul'); + ul.className = 'plan-items'; + shown.forEach((it, i) => { + const ui = planItemUi(it.status, i === curInShown); + const li = document.createElement('li'); + li.className = 'plan-item ' + ui.cls; + const mark = document.createElement('span'); + mark.className = 'plan-item-mark'; + mark.textContent = ui.mark; + const txt = document.createElement('span'); + txt.className = 'plan-item-text'; + txt.textContent = it.content || ''; + li.append(mark, txt); + ul.append(li); + }); + frag.append(ul); + } + const foot = document.createElement('div'); + foot.className = 'plan-foot'; + const moreN = r.planShowAll ? 0 : (overflow || Math.max(0, list.length - shown.length)); + if (moreN > 0) { + const hint = document.createElement('span'); + hint.className = 'plan-more-hint'; + hint.textContent = planTpl(t('plan.overflow'), { n: moreN }); + foot.append(hint); + } + if (list.length > 3) { + const det = document.createElement('button'); + det.type = 'button'; + det.className = 'plan-btn'; + det.dataset.planDetails = '1'; + det.textContent = r.planShowAll ? t('plan.collapse') : t('plan.details'); + foot.append(det); + } + if (foot.childNodes.length) frag.append(foot); + } + planBarEl.replaceChildren(frag); +} + +async function planFetch(sess) { + if (!sess?.bridgeSessionId || !state.bridgeReady || !isActive(sess)) return; + try { + const res = await fetch(`${BRIDGE_ORIGIN}/session/${encodeURIComponent(sess.bridgeSessionId)}/plan`); + if (!res.ok) throw new Error(`plan ${res.status}`); + const data = await res.json(); + applyPlanPayload(sess, data.plan ?? data.result?.plan); + } catch (_) { /* 对齐 TUI:读盘/网络失败不立刻清空条 */ } +} + +async function planPoll(sess) { + await planFetch(sess); + planTick(sess); +} + +/* ═══════════════ 消息渲染 ═══════════════ */ +function stripAttachPlaceholders(text) { + return String(text || '').replace(/\[(Image|File)\s+#\d+\]\s*/g, '').trim(); +} +// 把消息文本里的 [Image #N]/[File #N] 占位符“按原位置”渲染成内联 chip(显示文件名),其余文本转义+换行, +// 这样消息里能看到附件在文本中的位置(卡片/缩略图照常另外渲染)。lookup(kind,n) 取该附件文件名。 +// 同时兜底去掉内联的本地上传路径(历史/conductor 回显)。 +function renderMsgTextWithChips(text, lookup) { + const s = String(text || '').replace(/[^\s]*desktop_uploads[^\s]*\s*/g, ''); + const esc = t2 => escapeHtml(t2).replace(/\n/g, '
'); + const re = /\[(Image|File)\s+#(\d+)\]/g; + let out = '', last = 0, m; + while ((m = re.exec(s))) { + out += esc(s.slice(last, m.index)); + const name = (lookup && lookup(m[1], Number(m[2]))) || (m[1] === 'Image' ? 'image' : 'file'); + out += `${escapeHtml(name)}`; + last = re.lastIndex; + } + out += esc(s.slice(last)); + return out.trim(); +} +function fileSubLabel(name) { + const m = String(name || '').match(/\.([^.]+)$/); + if (!m) return t('file.kindGeneric'); + const ext = m[1].toLowerCase(); + const docExts = ['pdf', 'doc', 'docx', 'rtf', 'odt', 'pages', 'tex']; + const sheetExts = ['xls', 'xlsx', 'csv', 'tsv', 'numbers', 'ods']; + const slideExts = ['ppt', 'pptx', 'key', 'odp']; + const codeExts = ['py', 'js', 'ts', 'tsx', 'jsx', 'java', 'c', 'cpp', 'h', 'hpp', 'rs', 'go', 'rb', 'php', 'sh', 'html', 'css', 'json', 'yaml', 'yml', 'xml', 'sql', 'md']; + const archiveExts = ['zip', 'tar', 'gz', 'rar', '7z', 'bz2']; + const audioExts = ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a']; + const videoExts = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv']; + if (docExts.includes(ext)) return t('file.kindDoc'); + if (sheetExts.includes(ext)) return t('file.kindSheet'); + if (slideExts.includes(ext)) return t('file.kindSlide'); + if (codeExts.includes(ext)) return t('file.kindCode') + ' · ' + ext.toUpperCase(); + if (archiveExts.includes(ext)) return t('file.kindArchive'); + if (audioExts.includes(ext)) return t('file.kindAudio'); + if (videoExts.includes(ext)) return t('file.kindVideo'); + return ext.toUpperCase(); +} +/** 无 turn_segs 时按 LLM Running 标记切轮(与 stapp.fold_turns 同源) */ +function splitContentTurnSegs(content) { + const src = String(content || ''); + const parts = src.split(/\**LLM Running \(Turn \d+\) \.\.\.\**/); + const segs = []; + for (let i = 1; i < parts.length; i++) { + const body = parts[i] || ''; + if (body.length || segs.length) segs.push(body); + } + if (segs.length > 1) return segs; + return src.length ? [src] : []; +} + +function assistantTurnSegs(msg) { + if (Array.isArray(msg?.turn_segs) && msg.turn_segs.length) return msg.turn_segs; + if (typeof msg?.content === 'string' && msg.content.length) return splitContentTurnSegs(msg.content); + return []; +} + +/** turn_segs 未就绪时回退 content(双轨兼容,不改 ljq 渲染主路径) */ +function draftSegsFromPartial(raw, m) { + const segs = Array.isArray(m.turn_segs) ? m.turn_segs : []; + if (segs.length && segs.some(s => (s || '').length > 0)) return segs; + const content = typeof raw.content === 'string' ? raw.content : (m.content || ''); + return content ? [content] : segs; +} +function firstNonEmptyTurnIndex(segs) { + const arr = Array.isArray(segs) ? segs : []; + for (let i = 0; i < arr.length; i++) { + if ((arr[i] || '').length > 0) return i; + } + return 0; +} +// 后端 curr_turn 是内部 0-based 下标。展示时优先当前 turn;若该 turn 为空,回退到最后一个非空 turn。 +function resolveVisibleTurnIndex(segs, preferredTurn) { + const arr = Array.isArray(segs) ? segs : []; + const first = firstNonEmptyTurnIndex(arr); + const preferred = Number.isFinite(Number(preferredTurn)) ? Number(preferredTurn) : -1; + if (preferred >= 0 && (arr[preferred] || '').length > 0) return preferred; + for (let i = arr.length - 1; i >= 0; i--) { + if ((arr[i] || '').length > 0) return i; + } + return Math.max(first, preferred >= 0 ? preferred : (arr.length ? arr.length - 1 : first)); +} +// 静态完整渲染:visibleTurn 之前的 seg 固化折叠;visibleTurn 作为当前正文展示。 +function renderAssistantTurnsHtml(segs, currTurn, withCursor = false) { + const arr = Array.isArray(segs) ? segs : []; + if (!arr.length) return ''; + const first = firstNonEmptyTurnIndex(arr); + const curr = resolveVisibleTurnIndex(arr, currTurn); + let html = ''; + for (let i = first; i < curr; i++) { + if ((arr[i] || '').length > 0) html += `

${renderTurnFold(arr[i] || '', i)}
`; + } + html += `
${renderTurnBody(arr[curr] || '')}${withCursor ? '' : ''}
`; + return html; +} +function assistantCopyText(msg) { + const segs = assistantTurnSegs(msg); + if (segs.length) { + // 复制保持旧行为:只复制当前/最后可见 turn,不复制已折叠历史 turn。 + const turn = resolveVisibleTurnIndex(segs, msg?.curr_turn); + return stripTurnMarker(segs[turn] || '').replace(/[\s\S]*?<\/summary>\s*/i, '').trim(); + } + return ''; +} +function msgNode(msg) { + const el = document.createElement('div'); + el.className = 'msg ' + (msg.role || 'system'); + if (msg.role === 'user') { + const shown = (typeof msg.display === 'string' && msg.display.length) ? msg.display : msg.content; + const imgsHtml = (msg.images && msg.images.length) + ? `
${msg.images.map(im => ``).join('')}
` + : ''; + const filesHtml = (msg.files && msg.files.length) + ? `
${msg.files.map(f => { + const name = f.name || 'file'; + const sub = fileSubLabel(name); + return `
${GA_ICON('fileText')}${escapeHtml(name)}${escapeHtml(sub)}
`; + }).join('')}
` + : ''; + const chipText = renderMsgTextWithChips(shown, (kind, n) => { + const hit = (kind === 'Image' ? (msg.images || []) : (msg.files || [])).find(x => x.id === 'f-' + n); + return hit && (hit.name || ''); + }); + const textHtml = chipText ? `
${chipText}
` : ''; + el.innerHTML = `
${filesHtml}${imgsHtml}${textHtml}
`; + } + else if (msg.role === 'assistant') { + const segs = assistantTurnSegs(msg); + let html = renderAssistantTurnsHtml(segs, msg.curr_turn, false); + if (msg.stopped) html += `

[${escapeHtml(t('status.stopped'))}]

`; + el.innerHTML = `
${html}
`; + postRenderEnhance(el.querySelector('.bubble')); + } + else if (msg.role === 'error') el.innerHTML = `
${escapeHtml(msg.content)}
`; + else el.innerHTML = `
${escapeHtml(msg.content)}
`; + if (msg.role === 'user' || msg.role === 'assistant') { + const copyBtn = document.createElement('button'); + copyBtn.className = 'bubble-copy-btn'; + copyBtn.title = t('act.copy'); + copyBtn.innerHTML = SVG_COPY_ICON; + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const text = (msg.role === 'user') + ? stripAttachPlaceholders((typeof msg.display === 'string' && msg.display.length) ? msg.display : (msg.content || '')) + : assistantCopyText(msg); + navigator.clipboard.writeText(text).then(() => { + copyBtn.innerHTML = SVG_CHECK_ICON; + setTimeout(() => { copyBtn.innerHTML = SVG_COPY_ICON; }, 1500); + }); + }); + el.appendChild(copyBtn); + } + return el; +} +function collabItemToMsg(item) { + const attach = arr => (arr || []).map(x => { + const sid = x.sid != null ? x.sid : (String(x.id || '').startsWith('f-') ? String(x.id).slice(2) : x.id); + return { id: 'f-' + sid, name: x.name, path: x.path, dataUrl: x.dataUrl }; + }); + if (item.role === 'user') { + return { role: 'user', content: item.msg, display: item.msg, images: attach(item.images), files: attach(item.files) }; + } + if (item.role === 'conductor') return { role: 'assistant', turn_segs: [item.msg || ''], curr_turn: 0 }; + if (item.role === 'error') return { role: 'error', content: item.msg || '' }; + return { role: 'system', content: item.msg || '' }; +} +function renderAllMessages(sess) { + const box = ensureMsgs(); box.innerHTML = ''; + for (const m of sess.messages) box.appendChild(msgNode(m)); + syncAskUserUi(); + // badge 恢复在 pollSession finally 中执行(此时 messages 已通过异步加载填充) + refreshEmptyState(sess); scrollBottom(true); +} +// 遍历消息对,用 ts 差值恢复 badge;对运行中任务恢复 taskStartedAt +function restoreElapsedBadges(sess, box) { + const msgs = sess.messages; + if (!msgs || !msgs.length) return; + const nodes = box.querySelectorAll('.msg'); + let lastUserTs = null; + for (let i = 0; i < msgs.length; i++) { + if (msgs[i].role === 'user') { + lastUserTs = msgs[i].ts ? msgs[i].ts * 1000 : null; // 无 ts 则重置 + } else if (msgs[i].role === 'assistant') { + if (lastUserTs && msgs[i].ts) { + const elapsed = msgs[i].ts * 1000 - lastUserTs; + if (elapsed > 0 && nodes[i]) { + ensureTaskElapsedBadge(nodes[i], lastUserTs, msgs[i].ts * 1000); + } + } + lastUserTs = null; + } + } + // 运行中任务:最后一条是 user 且 session busy,恢复实时计时 + if (lastUserTs && rt(sess).busy) { + const r = rt(sess); + r.taskStartedAt = lastUserTs; + r.taskEndedAt = null; + } +} +function appendMessage(sess, msg) { + if (!isActive(sess)) return; + const el = msgNode(msg); + ensureMsgs().appendChild(el); + if (msg.role === 'assistant') { + const r = rt(sess); + if (r.taskStartedAt) { + ensureTaskElapsedBadge(el, r.taskStartedAt, r.taskEndedAt || Date.now()); + r.taskStartedAt = null; r.taskEndedAt = null; + } + } + refreshEmptyState(sess); scrollBottom(true); + if (msg.role === 'assistant' || msg.role === 'user') syncAskUserUi(); +} +function isNearBottom(threshold = 80) { + return msgArea.scrollHeight - msgArea.scrollTop - msgArea.clientHeight < threshold; +} +function scrollBottom(force) { + if (force || isNearBottom()) { + requestAnimationFrame(() => { msgArea.scrollTop = msgArea.scrollHeight; }); + } +} +/* ═══════════════ 打字机效果 (PR移植) ═══════════════ */ +const TW_SPEED = 10; // 逐字步长;paintDraft 只重绘当前轮 +const TW_INTERVAL = 35; // ms +const TW_CATCHUP_THRESHOLD = 480; // 积压过大时加速追平 +const TW_CATCHUP_MULTIPLIER = 8; +const TW_RECOVER_MIN = 1200; // 保留常量;刷新恢复改由 snapDraftRecover 一律对齐 +const DRAFT_INTERACT_MS = 520; // 用户滚代码块/点折叠时暂缓 DOM 重写 + +function isDraftInteractFrozen(r) { + return Date.now() < (r.draftFreezeUntil || 0); +} +function armDraftInteractFreeze(r, ms = DRAFT_INTERACT_MS) { + r.draftFreezeUntil = Math.max(r.draftFreezeUntil || 0, Date.now() + ms); +} +function snapshotDraftScroll(root) { + if (!root) return []; + return [...root.querySelectorAll('.bubble .code-block pre, .bubble .fold-pre')].map(n => n.scrollTop); +} +function restoreDraftScroll(root, tops) { + if (!root || !tops.length) return; + const nodes = root.querySelectorAll('.bubble .code-block pre, .bubble .fold-pre'); + tops.forEach((top, i) => { if (nodes[i] && top > 0) nodes[i].scrollTop = top; }); +} +function bindDraftInteractGuard(el, r) { + if (!el || el.dataset.gaDraftGuard) return; + el.dataset.gaDraftGuard = '1'; + const arm = () => { if (!r._suppressToggleFreeze) armDraftInteractFreeze(r); }; + el.addEventListener('mousedown', (e) => { + if (e.target.closest('details summary, .code-block pre, .fold-pre')) armDraftInteractFreeze(r); + }, true); + el.addEventListener('wheel', (e) => { + if (e.target.closest('.code-block pre, .fold-pre')) armDraftInteractFreeze(r); + }, { capture: true, passive: true }); + el.addEventListener('toggle', (e) => { + if (e.target.matches('details')) arm(); + }, true); +} +function resetTypewriterState(r) { + if (r.twState?.timer) clearInterval(r.twState.timer); + r.twState = null; + r.draftRecoverPending = false; + r.draftStreamBaseline = 0; + r._draftPaintBody = ''; +} + +/** 刷新/hydrate/重连:已有 partial 一次性对齐全文并单帧绘制,不重播打字机 */ +function snapDraftRecover(r) { + if (!r.draftRecoverPending || !r.draftEl) return false; + if (!r.twState) r.twState = { turn: 0, shown: 0, timer: null }; + const tw = r.twState; + if (tw.timer) { clearInterval(tw.timer); tw.timer = null; } + const segs = Array.isArray(r.draftSegs) ? r.draftSegs : []; + const turn = Math.max(0, Number(r.draftTurn) || 0); + tw.turn = turn; + tw.shown = (segs[turn] || '').length; + r.draftRecoverPending = false; + r.draftStreamBaseline = tw.shown; + r._draftPaintBody = ''; + ensureDraftFrozenThrough(r, turn); + paintDraft(r, turn, segs[turn] || ''); + return true; +} + +function renderDraft(sess) { + const r = rt(sess); + if (!isActive(sess)) return; + const box = ensureMsgs(); + if (!r.draftEl || r.draftEl.parentNode !== box) { + r.draftEl = document.createElement('div'); r.draftEl.className = 'msg assistant'; box.appendChild(r.draftEl); + bindDraftInteractGuard(r.draftEl, r); + if (r.taskStartedAt) ensureTaskElapsedBadge(r.draftEl, r.taskStartedAt, null); + } + if (!r.twState) r.twState = { turn: 0, shown: 0, timer: null }; + const tw = r.twState; + const backendTurn = Math.max(0, Number(r.draftTurn) || 0); + if (tw.turn == null || tw.turn < 0) tw.turn = 0; + if (tw.turn > backendTurn) tw.turn = backendTurn; + // 流式渲染模型:segs 是源数据;tw.turn 是正在打字的 turn;tw.turn 之前的 DOM 一旦 frozen 就不再动。 + ensureDraftFrozenThrough(r, tw.turn); + + const tick = () => { + const segs = Array.isArray(r.draftSegs) ? r.draftSegs : []; + const backendTurnNow = Math.max(0, Number(r.draftTurn) || 0); + if (tw.turn == null || tw.turn < 0) tw.turn = 0; + if (tw.turn > backendTurnNow) tw.turn = backendTurnNow; + r.streamTurn = tw.turn; + const cur = segs[tw.turn] || ''; + + // 当前 turn 打完,并且后端已进入后续 turn:当前 DOM 固化,然后打字机推进到下一 turn。 + if (tw.shown >= cur.length && backendTurnNow > tw.turn) { + paintDraft(r, tw.turn, cur); + freezeCurrentTurnDom(r, tw.turn); + tw.turn += 1; + tw.shown = 0; + r.streamTurn = tw.turn; + return; + } + + if (tw.shown >= cur.length) return; // 中途不停止 timer,等新 partial/done。 + if (isDraftInteractFrozen(r)) return; + + const t0 = performance.now(); + const backlog = cur.length - tw.shown; + let step = backlog > TW_CATCHUP_THRESHOLD ? TW_SPEED * TW_CATCHUP_MULTIPLIER : TW_SPEED; + const last = tw.lastElapsed || 0; + if (last > 60) step = Math.max(step, Math.ceil(backlog / 3)); + else step = Math.min(step, 120); + tw.shown = Math.min(tw.shown + step, cur.length); + paintDraft(r, tw.turn, cur.slice(0, tw.shown)); + tw.lastElapsed = performance.now() - t0; + }; + + if (snapDraftRecover(r)) { + if (!tw.timer) tw.timer = setInterval(tick, TW_INTERVAL); + refreshEmptyState(sess); + return; + } + + if (!tw.timer) tw.timer = setInterval(tick, TW_INTERVAL); + if (!isDraftInteractFrozen(r)) tick(); + refreshEmptyState(sess); +} + +function ensureDraftFrozenThrough(r, currTurn) { + if (!r.draftEl) return; + const segs = Array.isArray(r.draftSegs) ? r.draftSegs : []; + const upto = Math.max(0, Number(currTurn) || 0); + let bubble = r.draftEl.querySelector(':scope > .bubble.md'); + if (!bubble) { + bubble = document.createElement('div'); + bubble.className = 'bubble md'; + r.draftEl.appendChild(bubble); + } + const cur = bubble.querySelector(':scope > .turn-cur'); + for (let turn = 0; turn < upto; turn++) { + if (bubble.querySelector(`:scope > .turn-frozen[data-turn="${turn}"]`)) continue; + const frozen = document.createElement('div'); + frozen.className = 'turn-frozen'; + frozen.dataset.turn = turn; + frozen.innerHTML = renderTurnFold(segs[turn] || '', turn); + if (cur) bubble.insertBefore(frozen, cur); + else bubble.appendChild(frozen); + postRenderEnhance(frozen); + } +} + +function freezeCurrentTurnDom(r, turn) { + if (!r.draftEl) return; + const bubble = r.draftEl.querySelector(':scope > .bubble.md'); + if (!bubble) return; + const cur = bubble.querySelector(':scope > .turn-cur'); + if (!cur) return; + cur.className = 'turn-frozen'; + cur.dataset.turn = turn; + cur.innerHTML = renderTurnFold((r.draftSegs || [])[turn] || '', turn); + postRenderEnhance(cur); +} + +function paintDraft(r, turn, visibleCurrBody) { + if (!r.draftEl || isDraftInteractFrozen(r)) return; + const wasNear = isNearBottom(); + let bubble = r.draftEl.querySelector(':scope > .bubble.md'); + if (!bubble) { + bubble = document.createElement('div'); + bubble.className = 'bubble md'; + r.draftEl.appendChild(bubble); + } + let cur = bubble.querySelector(':scope > .turn-cur'); + if (!cur) { + cur = document.createElement('div'); + cur.className = 'turn-cur'; + bubble.appendChild(cur); + } + cur.dataset.turn = turn ?? 0; + const body = visibleCurrBody || ''; + const prevBody = r._draftPaintBody || ''; + if (!tryPatchInflightToolDom(cur, body, prevBody)) { + cur.innerHTML = renderTurnBody(body) + ''; + postRenderEnhance(cur); + } else if (!cur.querySelector('.cursor')) { + cur.insertAdjacentHTML('beforeend', ''); + } + r._draftPaintBody = body; + + if (wasNear) { + const inCodeScroll = document.activeElement?.closest?.('.code-block pre, .fold-pre') + && r.draftEl.contains(document.activeElement); + if (!inCodeScroll) scrollBottom(); + } +} + +function flushTypewriter(sess) { + resetTypewriterState(rt(sess)); +} + +/* ═══════════════ 运行状态 ═══════════════ */ +function pageStatusBar(btnEl) { + const label = btnEl?.querySelector('.rs-label'); + return { + /** state: 'ready' | 'busy' | 'offline' | 'connecting';兼容旧调用 set(text, true) */ + set(text, state = 'ready') { + if (!btnEl) return; + const mode = state === true ? 'busy' : (state === false ? 'ready' : state); + btnEl.classList.remove('busy', 'offline', 'connecting'); + if (mode === 'busy') btnEl.classList.add('busy'); + else if (mode === 'offline') btnEl.classList.add('offline'); + else if (mode === 'connecting') btnEl.classList.add('connecting'); + if (label) label.textContent = text ?? ''; + }, + setBusy(text) { this.set(text, 'busy'); }, + setReady() { this.set(t('status.ready'), 'ready'); }, + setDisconnected() { this.set(t('status.disconnected'), 'offline'); }, + setConnecting() { this.set(t('status.connecting'), 'connecting'); }, + }; +} +function refreshStatusLabel() { + const s = activeSess(); + if (s && rt(s).busy) { + chatStatus.setBusy(formatTaskElapsed(Date.now() - (rt(s).taskStartedAt || Date.now()))); + } else if (state.bridgeReady) { + chatStatus.setReady(); + } else { + chatStatus.setDisconnected(); + } +} + +/* ═══════════════ 消息计时 ═══════════════ */ +function formatTaskElapsed(ms) { + const v = Number(ms); + if (!Number.isFinite(v) || v < 0) return ''; + const sec = Math.round(v / 1000); + if (sec < 60) return t('timing.elapsed').replace('{t}', `${Math.max(1, sec)}s`); + const min = Math.floor(sec / 60), s = sec % 60; + if (min < 60) return t('timing.elapsed').replace('{t}', `${min}m ${s}s`); + const hr = Math.floor(min / 60), m = min % 60; + return t('timing.elapsed').replace('{t}', `${hr}h ${m}m`); +} + +function ensureTaskElapsedBadge(wrap, startedAt, endedAt) { + if (!wrap || !startedAt) return null; + let badge = wrap.querySelector(':scope > .task-elapsed'); + if (!badge) { + badge = document.createElement('div'); + badge.className = 'task-elapsed'; + wrap.prepend(badge); + } + const elapsed = (endedAt || Date.now()) - startedAt; + badge.textContent = formatTaskElapsed(elapsed); + badge.dataset.live = endedAt ? '' : '1'; + return badge; +} + +function startTaskTimer(sess) { + const r = rt(sess); + if (r.taskStartedAt) return; // 已在计时,不重置 + // 优先从消息时间戳恢复(刷新后持久化) + const msgs = sess.messages; + let restored = 0; + if (msgs && msgs.length) { + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role === 'user' && msgs[i].ts) { restored = msgs[i].ts * 1000; break; } + } + } + r.taskStartedAt = restored || Date.now(); + r.taskEndedAt = null; + if (r.taskTimerId) clearInterval(r.taskTimerId); + r.taskTimerId = setInterval(() => { + if (!r.taskStartedAt) return; + const el = r.draftEl || document.querySelector('.msg-list .msg.assistant:last-child'); + if (el) ensureTaskElapsedBadge(el, r.taskStartedAt, null); + // 更新左上角状态栏显示实时耗时 + if (isActive(sess)) { + chatStatus.setBusy(formatTaskElapsed(Date.now() - r.taskStartedAt)); + } + }, 1000); +} + +function stopTaskTimer(sess) { + const r = rt(sess); + if (r.taskTimerId) { clearInterval(r.taskTimerId); r.taskTimerId = null; } + if (!r.taskStartedAt) return; + r.taskEndedAt = Date.now(); +} + +function setBusy(sess, busy) { + const r = rt(sess); + if (r.busy && !busy) resetTypewriterState(r); + r.busy = busy; + if (busy) startTaskTimer(sess); else stopTaskTimer(sess); + if (!isActive(sess)) return; + if (busy) { + chatStatus.setBusy(formatTaskElapsed(Date.now() - (r.taskStartedAt || Date.now()))); + } else if (state.bridgeReady) { + chatStatus.setReady(); + } else { + chatStatus.setDisconnected(); + } + if (sendBtn) { + sendBtn.classList.toggle('is-stop', busy); + sendBtn.setAttribute('aria-label', busy ? t('act.stop') : t('act.send')); + sendBtn.title = busy ? t('act.stop') : ''; + } +} +// run-toggle 现为纯状态展示组件:运行中转红,不再响应点击(停止改由发送键的录制键承担) + +/* ═══════════════ 会话 ═══════════════ */ +function isUntitled(x) { return !x || /^(new chat|新对话|新会话)$/i.test(String(x).trim()); } +function sortedSessions() { + // display order: pinned first, then most-recently-active. [0] is the topmost. + return [...state.sessions.values()].sort((a, b) => { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return (b.lastActiveTs || 0) - (a.lastActiveTs || 0); + }); +} +function renderSessionList() { + convListEl.innerHTML = ''; + const query = (searchInput ? searchInput.value : '').trim().toLowerCase(); + const all = sortedSessions(); + const filtered = query + ? all.filter(s => { + const title = displayTitle(s).toLowerCase(); + const hasMsg = s.messages && s.messages.some(m => (m.text || '').toLowerCase().includes(query)); + return title.includes(query) || hasMsg; + }) + : all; + if (filtered.length === 0) { + const e = document.createElement('div'); + e.className = 'conv-empty'; e.textContent = t('conv.emptyList'); + convListEl.appendChild(e); return; + } + for (const sess of filtered) { + const r = state.runtime.get(sess.id); + const busy = !!(r && r.busy); + const item = document.createElement('div'); + item.className = 'conv-item' + (currentPage === 'chat' && sess.id === state.activeId ? ' active' : '') + (busy ? '' : ' idle'); + item.dataset.id = sess.id; + const pinSvg = sess.pinned ? GA_ICON('pushPinSimple', 'ci-pin') : ''; + item.innerHTML = + `
` + + `
${pinSvg}${escapeHtml(displayTitle(sess))}
` + + `
${busy ? t('status.running') : t('status.idle')}
` + + ``; + convListEl.appendChild(item); + } +} +if (searchInput) searchInput.addEventListener('input', () => renderSessionList()); +async function ensureBridgeSession(sess) { + if (sess.bridgeSessionId) return sess.bridgeSessionId; + const res = await window.ga.rpc('session/new', { cwd: '', mcp_servers: [] }); + if (res?.error) throw new Error(res.error.message || res.error); + sess.bridgeSessionId = res.sessionId || res.result?.sessionId; + return sess.bridgeSessionId; +} +// 仿 TUI(continue_cmd.py 的 _preview_text 思路):sess.title 只在用户手动 rename 时被填, +// 平时为空;sidebar 显示名实时从消息派生 —— 优先取最后一段 assistant 输出里的 ..., +// 其次用首条用户消息纯文本,都没有时回退到 t('conv.defaultTitle')。 +function isAutoTitle(x) { + const s = String(x || '').trim(); + if (!s) return true; + if (/^(new chat|新对话|新会话)$/i.test(s)) return true; + if (/^agent-\d+$/i.test(s)) return true; // 兼容上一轮误存的 agent-N + return false; +} +function displayTitle(sess) { + if (sess && sess.title && !isAutoTitle(sess.title)) return sess.title; + const msgs = (sess && sess.messages) || []; + // 1) 优先:最后一段 assistant 文本里的 ... + for (let i = msgs.length - 1; i >= 0; i--) { + const m = msgs[i]; + if (!m || m.role !== 'assistant') continue; + const txt = assistantStructuredText(m); + const sm = /([\s\S]*?)<\/summary>/i.exec(txt); + if (sm && sm[1].trim()) { + const line = sm[1].trim().split('\n')[0].trim(); + if (line) return line.length > 60 ? line.slice(0, 60) + '…' : line; + } + } + // 2) 兜底:首条用户消息纯文本(去附件占位符) + for (const m of msgs) { + if (!m || m.role !== 'user') continue; + const raw = typeof m.content === 'string' ? m.content : (m.display || ''); + const clean = stripAttachPlaceholders(raw).trim(); + if (clean) return clean.length > 40 ? clean.slice(0, 40) + '…' : clean; + } + return t('conv.defaultTitle'); +} +async function newSession() { + const localId = 'local-' + Date.now() + '-' + Math.random().toString(16).slice(2); + const sess = { id: localId, bridgeSessionId: null, title: '', messages: [], untitled: true, lastActiveTs: Date.now() }; + state.sessions.set(localId, sess); + try { + await ensureBridgeSession(sess); + state.sessions.delete(localId); + sess.id = sess.bridgeSessionId; + state.sessions.set(sess.id, sess); + } catch (e) { showError(t('err.newSession') + ': ' + (e.message || e)); } + setActiveSession(sess.id); + saveSessions(); + renderSessionList(); +} +function sessionNeedsHydrate(sess) { + return !!(sess?.bridgeSessionId && state.bridgeReady && !sess.messages.length); +} + +function runSessionHydrate(sess) { + setSessionLoading(true); + const tid = setTimeout(() => { + if (isActive(sess)) setSessionLoading(false); + }, HYDRATE_LOADING_TIMEOUT_MS); + return hydrateSession(sess).finally(() => { + clearTimeout(tid); + if (isActive(sess)) setSessionLoading(false); + }); +} + +function setActiveSession(id) { + setSessionLoading(false); + state.activeId = id; + if (id) localStorage.setItem('ga_active', id); // 持久化当前会话,刷新后固定恢复它 + const sess = state.sessions.get(id); + if (!sess) return; + if (msgsEl) msgsEl.innerHTML = ''; + const r = rt(sess); + r.draftEl = null; + resetTypewriterState(r); + renderAllMessages(sess); + setBusy(sess, rt(sess).busy); + renderSessionList(); + refreshPlanBar(null); + syncPlanPollTimer(); + if (!sess.bridgeSessionId || !state.bridgeReady) return; + if (sessionNeedsHydrate(sess)) { + runSessionHydrate(sess); + } else { + restoreElapsedBadges(sess, ensureMsgs()); + planPoll(sess); + } +} +async function closeSession(id) { + const sess = state.sessions.get(id); + if (sess && sess.bridgeSessionId) { + try { await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId }); } catch (_) {} + fetch(`${BRIDGE_ORIGIN}/session/${sess.bridgeSessionId}`, { method: 'DELETE' }).catch(() => {}); + } + state.sessions.delete(id); state.runtime.delete(id); + if (state.activeId === id) { + const next = (sortedSessions()[0] || {}).id || null; // 切到列表最靠上的会话 + if (next) setActiveSession(next); + else { state.activeId = null; localStorage.removeItem('ga_active'); if (msgsEl) msgsEl.innerHTML = ''; refreshEmptyState(null); refreshStatusLabel(); } + } + saveSessions(); + renderSessionList(); +} + +const convMenu = document.getElementById('conv-menu'); +let menuTargetId = null; +convListEl.addEventListener('click', (e) => { + const more = e.target.closest('.ci-more'); + if (more) { + e.stopPropagation(); + menuTargetId = more.closest('.conv-item').dataset.id; + // 根据当前会话置顶状态切菜单文案:置顶 / 取消置顶 + const tgt = state.sessions.get(menuTargetId); + const pinSpan = convMenu.querySelector('[data-act="pin"] [data-i18n]'); + if (pinSpan) { + const k = tgt && tgt.pinned ? 'ctx.unpin' : 'ctx.pin'; + pinSpan.setAttribute('data-i18n', k); + pinSpan.textContent = t(k); + } + convMenu.hidden = false; + const rect = more.getBoundingClientRect(); + convMenu.style.top = (rect.bottom + 4) + 'px'; + convMenu.style.left = (rect.right - convMenu.offsetWidth) + 'px'; + return; + } + const it = e.target.closest('.conv-item'); + if (it && it.dataset.id) { + setActiveSession(it.dataset.id); + const chatNav = nav.querySelector('.nav-item[data-page="chat"]'); + if (chatNav && !chatNav.classList.contains('active')) chatNav.click(); + } +}); +convMenu.addEventListener('click', (e) => { + e.stopPropagation(); + const act = e.target.closest('.ctx-item')?.dataset.act; + const sess = menuTargetId && state.sessions.get(menuTargetId); + if (sess && act === 'pin') { + if (sess.pinned) { + sess.pinned = false; // 取消置顶 + 放到 pinned 之后、其它 unpinned 之前(unpinned 区域顶部) + const others = [...state.sessions.values()].filter(s => s.id !== sess.id); + const m = new Map(); + for (const s of others) if (s.pinned) m.set(s.id, s); // 先所有仍 pinned 的 + m.set(sess.id, sess); // 再本会话(刚 unpinned) + for (const s of others) if (!s.pinned) m.set(s.id, s); // 再其它 unpinned + state.sessions = m; + } else { + sess.pinned = true; // 置顶 + 移到列表顶 + const m = new Map(); m.set(sess.id, sess); + for (const [k, v] of state.sessions) if (k !== sess.id) m.set(k, v); + state.sessions = m; + } + saveSessions(); + patchSession(sess, { pinned: sess.pinned }); + renderSessionList(); + } else if (sess && act === 'rename') { + convMenu.hidden = true; + const item = convListEl.querySelector(`.conv-item[data-id="${sess.id}"]`); + if (!item) return; + const titleEl = item.querySelector('.ci-title'); + if (!titleEl) return; + const oldTitle = sess.title || ''; + const inp = document.createElement('input'); + inp.className = 'ci-rename-input'; + inp.maxLength = 50; + inp.value = oldTitle; + titleEl.replaceWith(inp); + bindToastLimit(inp); + inp.focus(); + inp.select(); + const finish = (save) => { + if (inp._done) return; + inp._done = true; + const val = inp.value.trim(); + if (save && val && val !== oldTitle) { + sess.title = val; + sess.untitled = false; + saveSessions(); + patchSession(sess, { title: val, untitled: false }); + const history = tokLoadHistory(); + const sid = sess.bridgeSessionId || sess.id; + let changed = false; + history.forEach(h => { if (h.sessionId === sid) { h.title = val; changed = true; } }); + if (changed) tokSaveHistory(history); + } + renderSessionList(); + }; + inp.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); + inp.addEventListener('blur', () => finish(true)); + return; + } else if (sess && act === 'del') { + closeSession(sess.id); + } + convMenu.hidden = true; +}); +document.addEventListener('click', () => { convMenu.hidden = true; }); +newConvBtn.addEventListener('click', (e) => { e.preventDefault(); newSession(); }); + +/* ═══════════════ 轮询 + 流式 ═══════════════ */ +function normalize(m) { + const o = { id: Number(m.id || 0), role: m.role || 'system' }; + if (m.role !== 'assistant') o.content = m.content || ''; + if (typeof m.display === 'string' && m.display.length) o.display = m.display; + if (m.stopped) o.stopped = true; + if (m.images) o.images = m.images; + if (m.files) o.files = m.files; + if (m.ts) o.ts = m.ts; + // [turn_segs双轨] 透传结构化轮数组(若后端提供);落库消息与 partial 都可能带 + if (Array.isArray(m.turn_segs)) o.turn_segs = m.turn_segs; + if (typeof m.curr_turn === 'number') o.curr_turn = m.curr_turn; + if (m.role === 'assistant' && !o.turn_segs?.length && typeof m.content === 'string') o.content = m.content; + return o; +} +function upsert(sess, raw, partial) { + const m = normalize(raw); const r = rt(sess); + if (partial && m.role === 'assistant') { + if (!r.draftEl) resetTypewriterState(r); + const prevLen = (Array.isArray(r.draftSegs) ? (r.draftSegs[r.draftTurn] || '') : '').length; + r.draftSegs = draftSegsFromPartial(raw, m); + r.draftTurn = (typeof m.curr_turn === 'number') ? m.curr_turn : Math.max(0, r.draftSegs.length - 1); + const curLen = (r.draftSegs[r.draftTurn] || '').length; + const wasEmpty = prevLen === 0; + // 新 draft(含刷新/停止后再开):对齐已有 partial,避免从 0 重播打字机 + if ((!r.draftEl || wasEmpty) && curLen > 0) { + r.draftRecoverPending = true; + } + const tw = r.twState; + if (tw && curLen > (r.draftStreamBaseline || 0)) { + const baseline = r.draftStreamBaseline || 0; + if (tw.shown < baseline) tw.shown = baseline; + } + r.draftStreamBaseline = curLen; + if (isActive(sess)) renderDraft(sess); + return; + } + if (!m.id || r.seen.has(m.id)) return; + r.seen.add(m.id); r.lastId = Math.max(r.lastId, m.id); + if (m.role === 'assistant' && r.draftEl) { + // done 收尾:用 final m 原地重画 bubble(保留 ljq 的"复用 draftEl 不闪烁"优化), + // 同时彻底丢掉 partial 累积的 DOM——避免 frp 高延迟下漏掉的最后一拍丢字。 + // assistantTurnSegs(m) 双轨处理 turn_segs/content,跟 msgNode refresh 路径同源。 + flushTypewriter(sess); + const segs = assistantTurnSegs(m); + const curr = resolveVisibleTurnIndex(segs, m.curr_turn); + r.draftSegs = segs; + r.draftTurn = curr; + r.streamTurn = curr; + if (!r.twState) r.twState = { shown: 0, timer: null }; + if (r.twState.timer) { clearInterval(r.twState.timer); r.twState.timer = null; } + r.twState.turn = curr; + r.twState.shown = (segs[curr] || '').length; + r.draftRecoverPending = false; + if (isActive(sess)) { + let bubble = r.draftEl.querySelector(':scope > .bubble.md'); + if (!bubble) { + bubble = document.createElement('div'); + bubble.className = 'bubble md'; + r.draftEl.appendChild(bubble); + } + bubble.innerHTML = renderAssistantTurnsHtml(segs, curr, false); + postRenderEnhance(bubble); + } + const cursor = r.draftEl.querySelector('.cursor'); + if (cursor) cursor.remove(); + if (r.taskStartedAt) { + ensureTaskElapsedBadge(r.draftEl, r.taskStartedAt, r.taskEndedAt || Date.now()); + r.taskStartedAt = null; r.taskEndedAt = null; + } + if (!r.draftEl.querySelector('.bubble-copy-btn')) { + const copyBtn = document.createElement('button'); + copyBtn.className = 'bubble-copy-btn'; + copyBtn.title = t('act.copy'); + copyBtn.innerHTML = SVG_COPY_ICON; + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const text = assistantCopyText(m); + navigator.clipboard.writeText(text).then(() => { + copyBtn.innerHTML = SVG_CHECK_ICON; + setTimeout(() => { copyBtn.innerHTML = SVG_COPY_ICON; }, 1500); + }); + }); + r.draftEl.appendChild(copyBtn); + } + r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; r.streamTurn = 0; + sess.messages.push(m); + refreshEmptyState(sess); + if (m.role === 'assistant' || m.role === 'user') syncAskUserUi(); + saveSessions(); + return; + } + sess.messages.push(m); appendMessage(sess, m); + saveSessions(); +} + +async function fetchSessionPoll(sess, opts = {}) { + const r = rt(sess); + const sid = sess.bridgeSessionId || sess.id; + const afterId = opts.after ?? r.lastId ?? 0; + const limit = opts.limit ?? POLL_MSG_LIMIT; + const res = await window.ga.rpc('session/poll', { sessionId: sid, afterId, limit }); + if (res?.error) throw new Error(res.error.message || res.error); + return res.result || res; +} + +function applyPollResult(sess, result) { + if (result.partial) upsert(sess, result.partial, true); + for (const msg of (result.messages || [])) upsert(sess, msg, false); + const busy = result.status === 'running' || !!result.partial; + setBusy(sess, busy); + if (isActive(sess)) { + applyPlanPayload(sess, result.plan); + applyLiveModel(result.model, sess); + } + return busy; +} + +/** 渠道组随故障转移变化时,用运行态当前子模型刷新 chip(非渠道组/无 agent 时不动,保持静态显示) */ +function applyLiveModel(live, sess = activeSess()) { + const selected = (state.modelProfiles || []).find(p => (p.id ?? 0) === state.llmNo); + if (!selected || selected.kind !== 'mixin' || !live || !live.isMixin || !live.current) return; + state.liveModel = { ...live, sessionId: sess?.id || state.activeId }; + const label = `${t('model.aggregationShort')}${lang === 'en' ? ' (' : '('}${profileLabel(live.current) || live.current}${lang === 'en' ? ')' : ')'}`; + if (state.modelName !== label) { state.modelName = label; updateModelChip(); } +} + +/** hydrate 批量灌历史,避免逐条 appendMessage 触发全量重绘 */ +function hydrateHistoryMessages(sess, messages) { + const r = rt(sess); + for (const raw of (messages || [])) { + const m = normalize(raw); + if (!m.id || r.seen.has(m.id)) continue; + r.seen.add(m.id); + r.lastId = Math.max(r.lastId, m.id); + sess.messages.push(m); + } + if (isActive(sess)) renderAllMessages(sess); +} + +/** 拉历史:limit=0 一次拿全量(bridge 不截断);不等 idle,running 续交给 pollSession */ +async function hydrateSession(sess) { + try { + const result = await fetchSessionPoll(sess, { after: 0, limit: 0 }); + hydrateHistoryMessages(sess, result.messages); + if (result.partial) upsert(sess, result.partial, true); + const busy = result.status === 'running' || !!result.partial; + setBusy(sess, busy); + if (isActive(sess)) applyPlanPayload(sess, result.plan); + if (busy && !rt(sess).polling) pollSession(sess); + } catch (e) { + showError(t('err.poll') + ': ' + (e.message || e)); + setBusy(sess, false); + } finally { + if (isActive(sess)) { + restoreElapsedBadges(sess, ensureMsgs()); + syncAskUserUi(); + } + tokPollBridge(); + } +} + +async function pollSession(sess) { + const r = rt(sess); + if (r.polling) { r.pollAgain = true; return; } + r.polling = true; + r.pollAgain = false; + /* 手机切后台再回前台时,第一拍 fetch 经常用着死连接秒挂(Failed to fetch), + 但只要给链路 1-2 秒重建,后续就稳。原版一炸就 showError,体验糟。 + 改成:同一次 polling 循环里连续失败 ≥ MAX_ERRORS 次才放弃, + 单次失败做指数退避(1s / 2s / 4s / 8s),够 ride through 一次后台恢复抖动。 */ + const MAX_ERRORS = 5; + let consecutiveErrors = 0; + try { + do { + try { + const result = await fetchSessionPoll(sess); + consecutiveErrors = 0; + const busy = applyPollResult(sess, result); + if (busy) await new Promise(z => setTimeout(z, 500)); + else { + if (r.draftEl) { r.draftEl.remove(); r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; } + resetTypewriterState(r); + break; + } + } catch (innerErr) { + consecutiveErrors++; + if (consecutiveErrors >= MAX_ERRORS) throw innerErr; + const backoff = Math.min(8000, 1000 * Math.pow(2, consecutiveErrors - 1)); + await new Promise(z => setTimeout(z, backoff)); + } + } while (true); + } catch (e) { + showError(t('err.poll') + ': ' + (e.message || e)); + setBusy(sess, false); + } finally { + r.polling = false; renderSessionList(); + // 历史消息已全部加载,恢复已完成任务的耗时 badge + if (isActive(sess)) { + restoreElapsedBadges(sess, ensureMsgs()); + syncAskUserUi(); + } + tokPollBridge(); + if (r.pollAgain) { + r.pollAgain = false; + pollSession(sess); + } + } +} + +function removeUsedPendingFiles(usedFiles) { + if (!usedFiles.length) return; + const usedSids = new Set(usedFiles.map(f => f.sid)); + const touched = new Set(usedFiles.map(f => fileCtx(f))); + state.pendingFiles = state.pendingFiles.filter(f => !usedSids.has(f.sid)); + touched.forEach(ctx => renderThumbStrip(ctx)); +} + +function clearDraft(sess) { + const r = rt(sess); + resetTypewriterState(r); + if (r.draftEl) { r.draftEl.remove(); r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; } +} + +async function waitSessionIdle(sess, maxMs = 4000) { + const start = Date.now(); + while (rt(sess).busy && Date.now() - start < maxMs) { + await new Promise(z => setTimeout(z, 100)); + } + return !rt(sess).busy; +} + +function setSessionLoading(on) { + if (!msgArea || !sessionLoadingEl) return; + if (on && msgArea.classList.contains('is-loading')) return; + msgArea.classList.toggle('is-session-loading', !!on); + sessionLoadingEl.hidden = !on; + if (on && sessionLoadingEl.querySelector('[data-i18n]')) { + sessionLoadingEl.querySelector('[data-i18n]').textContent = t('chat.sessionLoading'); + } +} + +function setMsgLoading(on) { + if (msgArea) msgArea.classList.toggle('is-loading', !!on); + if (msgLoading) { + msgLoading.hidden = !on; + if (on) { + setSessionLoading(false); + scrollBottom(); + } + } +} + +function setComposerLocked(on) { + if (composerEl) composerEl.classList.toggle('is-locked', !!on); + if (inputEl) inputEl.contentEditable = on ? 'false' : 'true'; // contenteditable 无 readOnly,改切 contentEditable + if (sendBtn) { + sendBtn.disabled = !!on; + sendBtn.classList.toggle('is-busy', !!on); + sendBtn.setAttribute('aria-busy', on ? 'true' : 'false'); + } +} + +/** stapp.py 同款:运行中再发 → cancel 当前轮次,等 idle 后再提交新 prompt */ +async function interruptBeforeSend(sess) { + if (!rt(sess).busy) return true; + const t0 = Date.now(); + setMsgLoading(true); + try { + clearDraft(sess); + try { + const res = await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId || sess.id }); + if (res?.error) throw new Error(res.error.message || res.error); + } catch (e) { + showChanToast(t('err.stop') + ': ' + (e.message || e), '', 'err'); + return false; + } + showChanToast(t('sys.interruptPrev.hint'), '', 'info'); + const idle = await waitSessionIdle(sess); + clearDraft(sess); + if (!idle) { + showChanToast(t('err.interruptTimeout'), '', 'err'); + return false; + } + return true; + } finally { + const wait = Math.max(0, MIN_MSG_LOADING_MS - (Date.now() - t0)); + if (wait) await new Promise(r => setTimeout(r, wait)); + setMsgLoading(false); + } +} + +/* ═══════════════ 发送 / 取消 ═══════════════ */ +async function sendPrompt(text) { + text = String(text || '').trim(); + if (!text) return false; + if (!state.bridgeReady) { showError(t('err.bridge')); return false; } + if (!state.activeId) { await newSession(); if (!state.activeId) return false; } + const sess = activeSess(); const r = rt(sess); + if (r.busy) { + const interrupted = await interruptBeforeSend(sess); + if (!interrupted) return false; + } + // PLAN/AUTO 现在是预设功能(preset 卡片)一次性发送,不再是常驻 prefix + const composedPrompt = expandFilePlaceholders(text).trim(); + const usedFiles = collectUsedFiles(text); + const userMsg = { role: 'user', content: text, ts: Date.now() / 1000 }; + const previewImgs = usedFiles.filter(f => f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path, dataUrl: f.dataUrl || '' })); + if (previewImgs.length) userMsg.images = previewImgs; + const previewFiles = usedFiles.filter(f => !f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path })); + if (previewFiles.length) userMsg.files = previewFiles; + sess.messages.push(userMsg); appendMessage(sess, userMsg); + if (isPlanPresetPrompt(text)) { + const pr = rt(sess); + pr.planCollapsed = false; + pr.planShowAll = false; + const sidHint = (sess.bridgeSessionId || sess.id || 'sess').replace(/\//g, '_'); + applyPlanPayload(sess, { + active: true, placeholder: true, done: 0, total: 0, complete: false, + step: '', pathHint: `plan_${sidHint}/plan.md`, items: [], + }); + } + sess.lastActiveTs = Date.now(); + // 仿 TUI:不再从首条消息自动改名 —— 标题在 newSession 时已设为 agent-N, + // 之后只接受用户手动 rename。 + saveSessions(); + setBusy(sess, true); + try { + let sid = await ensureBridgeSession(sess); + try { + await bridgeFetch(`/session/${encodeURIComponent(sid)}/restore`, { method: 'POST', body: {} }); + } catch (restoreErr) { + if (/not found/i.test(restoreErr.message || '')) { + sess.bridgeSessionId = null; + sid = await ensureBridgeSession(sess); + state.sessions.delete(sess.id); + sess.id = sess.bridgeSessionId; + state.sessions.set(sess.id, sess); + state.activeId = sess.id; + localStorage.setItem('ga_active', sess.id); // 会话 id 因 bridge 重建而变更,同步持久化 + } + } + const res = await window.ga.rpc('session/prompt', { sessionId: sid, prompt: composedPrompt, display: text, llmNo: state.llmNo, + files: previewFiles, imageMetas: previewImgs.map(im => ({ name: im.name, path: im.path })) }); + if (res?.error) throw new Error(res.error.message || res.error); + removeUsedPendingFiles(usedFiles); + const uid = Number(res.userMessageId || res.result?.userMessageId || 0); + if (uid) { r.seen.add(uid); r.lastId = Math.max(r.lastId, uid); } + planPoll(sess); + pollSession(sess); + return true; + } catch (e) { + const em = { role: 'error', content: e.message || String(e) }; + sess.messages.push(em); appendMessage(sess, em); + setBusy(sess, false); + return false; + } +} +async function cancelPrompt() { + const sess = activeSess(); + if (!sess || !rt(sess).busy) return false; + try { + const res = await window.ga.rpc('session/cancel', { sessionId: sess.bridgeSessionId || sess.id }); + if (res?.error) throw new Error(res.error.message || res.error); + clearDraft(sess); + rt(sess).pollAgain = true; + return true; + } catch (e) { showError(t('err.stop') + ': ' + (e.message || e)); return false; } +} + +/* ═══════════════ 输入区 / slash / 预设 ═══════════════ */ +async function submitInput() { + if (_submitInFlight) return; + let text = composerText('chat'); + if (!text.trim()) return; + if (text.trim().startsWith('/')) { + inputEl.innerHTML = ''; + handleSlash(text.trim()); + return; + } + if (text.length > 20000) { + text = text.slice(0, 20000); + showToast(t('err.charLimit').replace('{n}', 20000), 'warn'); + } + _submitInFlight = true; + setComposerLocked(true); + try { + const sent = await sendPrompt(text); + if (sent) { + inputEl.innerHTML = ''; + } + } finally { + _submitInFlight = false; + setComposerLocked(false); + syncAskUserUi(); + } +} +sendBtn.addEventListener('click', (e) => { + e.preventDefault(); + const sess = activeSess(); + if (sess && rt(sess).busy) { cancelPrompt(); return; } // 运行中:发送键是录制键 → 纯停止 + submitInput(); +}); +inputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); submitInput(); } +}); +// 输入框的 input/paste 监听统一在 bindComposerUpload(ctx) 里绑(chat + collab 通用) +function showSystem(text) { + const sess = activeSess(); if (!sess) return; + const m = { role: 'system', content: text }; + sess.messages.push(m); appendMessage(sess, m); +} +function showError(text) { + const sess = activeSess(); + if (sess) { const m = { role: 'error', content: text }; sess.messages.push(m); appendMessage(sess, m); } + else console.error(text); +} +let _toastTimer = null; +function showToast(text) { + let el = document.getElementById('ga-toast'); + if (!el) { el = document.createElement('div'); el.id = 'ga-toast'; el.className = 'ga-toast'; document.body.appendChild(el); } + el.textContent = text; + el.classList.add('show'); + clearTimeout(_toastTimer); + _toastTimer = setTimeout(() => el.classList.remove('show'), 1800); +} +async function handleSlash(cmd) { + const name = cmd.slice(1).split(/\s+/)[0]; + switch (name) { + case 'help': showSystem(t('slash.help')); break; + case 'new': await newSession(); break; + case 'clear': { const s = activeSess(); if (s) { s.messages = []; renderAllMessages(s); } break; } + case 'stop': if (await cancelPrompt()) showSystem(t('sys.stopRequested')); break; + case 'settings': openSettings(); break; + default: showSystem(t('slash.unknown') + ': /' + name); + } +} +// 预设卡:按 data-preset 解耦(与翻译后的标题无关) +document.querySelectorAll('.feature-grid').forEach(grid => { + grid.addEventListener('click', (e) => { + const editBtn = e.target.closest('.fc-edit'); + if (editBtn) { + e.stopPropagation(); + const cp = state.customPresets.find(p => p.id === editBtn.dataset.editId); + if (cp) openCustomPresetEditor(cp); + return; + } + const xBtn = e.target.closest('.fc-x'); + if (xBtn) { + e.stopPropagation(); + const kind = xBtn.dataset.removeKind; + const id = xBtn.dataset.removeId; + if (kind === 'builtin') hideBuiltinPreset(id); + else if (kind === 'custom') removeCustomPreset(id); + return; + } + const card = e.target.closest('.fcard'); + if (!card || !grid.contains(card)) return; + const key = card.dataset.preset; + if (key === 'add') { closeModals(); openModal('custom-preset-modal'); resetCustomPresetForm(); return; } + if (card.classList.contains('fcard-custom')) { + const id = card.dataset.id; + const cp = state.customPresets.find(p => p.id === id); + if (cp) { closeModals(); sendPrompt(cp.prompt); } + return; + } + if (!key) { inputEl.focus(); closeModals(); return; } + const bp = BUILTIN_PRESETS.find(p => p.key === key); + if (bp?.navigate) { closeModals(); gaGoPage(bp.navigate); window.collabFocus?.(); return; } + const prompt = I18N[lang]['presetPrompt.' + key] || I18N.zh['presetPrompt.' + key]; + closeModals(); + if (prompt) sendPrompt(prompt); + }); +}); + +/* ═══════════════ 模型 / 设置 ═══════════════ */ +function updateModelChip() { + const name = state.modelName || ''; + if (modelNameEl) modelNameEl.textContent = name; + if (collabModelNameEl) collabModelNameEl.textContent = name; +} +function modelDisplayName(p, fallbackName) { + if (p && p.kind === 'mixin') { + // 静态回退显示「渠道组(首选模型名)」;运行后由 applyLiveModel 切到真实当前子模型。 + const primary = (p.members || [])[0]; + if (!primary) return t('model.aggregation'); + const open = lang === 'en' ? ' (' : '(', close = lang === 'en' ? ')' : ')'; + return `${t('model.aggregationShort')}${open}${profileLabel(primary) || primary}${close}`; + } + return profileLabel(fallbackName ?? (p && p.name)) || (fallbackName ?? (p && p.name)) || null; +} +async function selectModel(id, name) { + state.llmNo = id; + state.liveModel = null; + const p = (state.modelProfiles || []).find(x => (x.id ?? 0) === id); + state.modelName = modelDisplayName(p, name); + updateModelChip(); + renderSettingsModels(); + await persistUiPrefs(); +} +async function addToMixin(id) { + try { + const res = await bridgeFetch(`/model-profiles/${id}/mixin`, { method: 'POST', body: {} }); + if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed')); + state.modelProfiles = normalizeProfiles(res.profiles || []); + renderSettingsModels(); + } catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); } +} +async function removeFromMixin(id) { + try { + const res = await bridgeFetch(`/model-profiles/${id}/mixin`, { method: 'DELETE', body: {} }); + if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed')); + state.modelProfiles = normalizeProfiles(res.profiles || []); + renderSettingsModels(); + } catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); } +} +async function reorderMixin(members) { + try { + const res = await bridgeFetch('/model-profiles/mixin/order', { method: 'PUT', body: { members } }); + if (res?.ok === false || res?.error) throw new Error(res.error || t('err.mixinFailed')); + state.modelProfiles = normalizeProfiles(res.profiles || []); + renderSettingsModels(); + const active = state.modelProfiles.find(p => (p.id ?? 0) === state.llmNo); + if (active) { state.modelName = modelDisplayName(active); updateModelChip(); } + } catch (ex) { showChanToast(t('err.mixinFailed'), ex.message || '', 'err'); renderSettingsModels(); } +} +function flipReorder(container, mutate) { + const rows = [...container.querySelectorAll('.model-member:not(.dragging)')]; + const first = new Map(rows.map(el => [el, el.getBoundingClientRect()])); + mutate(); + rows.forEach(el => { + const a = first.get(el), b = el.getBoundingClientRect(); + if (!a) return; + const dx = a.left - b.left, dy = a.top - b.top; + if (!dx && !dy) return; + el.style.transition = 'none'; + el.style.transform = `translate(${dx}px, ${dy}px)`; + requestAnimationFrame(() => { + el.style.transition = 'transform .14s cubic-bezier(.2,.8,.2,1)'; + el.style.transform = ''; + }); + }); +} +function bindMixinDrag(body, members) { + let drag = null; + const clear = () => { + body.querySelectorAll('.model-member').forEach(x => x.classList.remove('dragging', 'drag-over')); + document.body.classList.remove('mixin-dragging'); + }; + body.addEventListener('pointerdown', (e) => { + const handle = e.target.closest('.model-member-drag'); + if (!handle || e.button !== 0) return; + const row = handle.closest('.model-member'); + if (!row) return; + e.preventDefault(); + handle.setPointerCapture?.(e.pointerId); + drag = { handle, row, name: row.dataset.member, order: [...members], original: [...members], pointerId: e.pointerId, over: null }; + row.classList.add('dragging'); + document.body.classList.add('mixin-dragging'); + }); + body.addEventListener('pointermove', (e) => { + if (!drag) return; + const over = document.elementFromPoint(e.clientX, e.clientY)?.closest('.model-member'); + if (!over || !body.contains(over) || over === drag.row) return; + const rect = over.getBoundingClientRect(); + const after = e.clientY > rect.top + rect.height / 2; + const overKey = `${over.dataset.member}:${after ? 'after' : 'before'}`; + if (overKey === drag.over) return; + drag.over = overKey; + const from = drag.order.indexOf(drag.name), overIdx = drag.order.indexOf(over.dataset.member); + if (from < 0 || overIdx < 0) return; + const [moved] = drag.order.splice(from, 1); + let insertAt = drag.order.indexOf(over.dataset.member) + (after ? 1 : 0); + drag.order.splice(insertAt, 0, moved); + flipReorder(body, () => { + if (after) body.insertBefore(drag.row, over.nextSibling); + else body.insertBefore(drag.row, over); + }); + }); + const finish = (e) => { + if (!drag) return; + drag.handle.releasePointerCapture?.(drag.pointerId); + const changed = JSON.stringify(drag.order) !== JSON.stringify(drag.original); + const next = drag.order; + drag = null; + clear(); + if (changed) reorderMixin(next); + }; + body.addEventListener('pointerup', finish); + body.addEventListener('pointercancel', finish); +} +const MODEL_ACT_EDIT = GA_ICON('pencilSimple'); +const MODEL_ACT_DEL = GA_ICON('trash'); +let editingModelId = null; + +function setModelApikeyMode(isAdd) { + const apikey = document.getElementById('model-apikey-input'); + const apikeyReq = document.querySelector('#model-apikey-label .field-req'); + if (!apikey) return; + apikey.required = isAdd; + apikey.dataset.i18nPh = isAdd ? 'model.apikeyPh' : 'model.apikeyKeep'; + if (isAdd) apikey.removeAttribute('data-optional-ph'); + else { apikey.value = ''; apikey.setAttribute('data-optional-ph', ''); } + if (apikeyReq) apikeyReq.hidden = !isAdd; +} + +/* ═══════════════ 官方模型快速接入(DeepSeek / 通义千问)═══════════════ */ +// 预填 API 地址 / 协议 / 模型,用户只需粘贴 API Key。apibase 末尾的 /v1 会被 +// 后端自动补成 /v1/chat/completions(见 mykey_template.py 的拼接规则)。 +const PROVIDER_PRESETS = { + deepseek: { + label: 'DeepSeek', descKey: 'pq.deepseekDesc', + protocol: 'oai', apibase: 'https://api.deepseek.com/v1', + model: 'deepseek-v4-pro', name: 'DeepSeek', + keyUrl: 'https://platform.deepseek.com/api_keys', + color: '#4D6BFE', tint: 'rgba(77,107,254,.12)', + logo: '', + }, + qwen: { + label: '通义千问', descKey: 'pq.qwenDesc', + protocol: 'oai', apibase: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.6-max-preview', name: '通义千问', + keyUrl: 'https://bailian.console.aliyun.com/?apiKey=1', + color: '#615CED', tint: 'rgba(97,92,237,.12)', + logo: '', + }, +}; +window.gaProviderPresets = PROVIDER_PRESETS; + +// 在「添加模型」弹窗顶部显示/隐藏接入指引横幅。key 为 null 时隐藏。 +function setModelGuide(key) { + const box = document.getElementById('model-guide'); + if (!box) return; + const p = key && PROVIDER_PRESETS[key]; + if (!p) { box.hidden = true; box.dataset.provider = ''; return; } + box.hidden = false; + box.dataset.provider = key; + const logo = document.getElementById('model-guide-logo'); + if (logo) { logo.innerHTML = p.logo || ''; logo.style.background = p.tint || ''; } + const nameEl = document.getElementById('model-guide-name'); + if (nameEl) nameEl.textContent = p.label; + const link = document.getElementById('model-guide-link'); + if (link) { link.href = p.keyUrl; link.textContent = t('guide.getKey').replace('{name}', p.label); } +} +window.gaRefreshModelGuide = () => { + const box = document.getElementById('model-guide'); + if (box && !box.hidden && box.dataset.provider) setModelGuide(box.dataset.provider); +}; + +function openAddModelFormForProvider(key) { + const p = PROVIDER_PRESETS[key]; + if (!p) return openAddModelForm(); + editingModelId = null; + const form = document.getElementById('add-model-form'); + const title = document.getElementById('model-form-title'); + const errEl = document.getElementById('add-model-err'); + if (title) title.dataset.i18n = 'modal.addModel'; + if (form) { + form.reset(); + form.model.value = p.model || ''; + form.apibase.value = p.apibase || ''; + form.name.value = p.name || ''; + const pr = form.querySelector(`input[name="protocol"][value="${p.protocol}"]`); + if (pr) pr.checked = true; + } + setModelApikeyMode(true); + if (errEl) { errEl.hidden = true; errEl.textContent = ''; } + setModelGuide(key); + openModal('add-model-modal'); + applyI18n(); + const apikey = document.getElementById('model-apikey-input'); + if (apikey) setTimeout(() => apikey.focus(), 60); +} + +function openAddModelForm() { + editingModelId = null; + const form = document.getElementById('add-model-form'); + const title = document.getElementById('model-form-title'); + const errEl = document.getElementById('add-model-err'); + if (title) title.dataset.i18n = 'modal.addModel'; + if (form) form.reset(); + setModelApikeyMode(true); + setModelGuide(null); + if (errEl) { errEl.hidden = true; errEl.textContent = ''; } + openModal('add-model-modal'); + applyI18n(); +} +async function openEditModelForm(id) { + editingModelId = id; + setModelGuide(null); + const errEl = document.getElementById('add-model-err'); + if (errEl) { errEl.hidden = true; errEl.textContent = ''; } + try { + const res = await bridgeFetch(`/model-profiles/${id}`); + const p = res.profile; + if (!p) throw new Error(t('err.modelSave')); + const form = document.getElementById('add-model-form'); + const title = document.getElementById('model-form-title'); + if (title) title.dataset.i18n = 'modal.editModel'; + if (form) { + form.model.value = p.model || ''; + form.apibase.value = p.apibase || ''; + form.name.value = p.name || ''; + form.max_retries.value = p.max_retries ?? 5; + form.connect_timeout.value = p.connect_timeout ?? 15; + form.read_timeout.value = p.read_timeout ?? 300; + // 编辑模式:按 varName 回填协议分段控件 + const pv = /claude/i.test(p.varName || '') ? 'claude' : 'oai'; + const pr = form.querySelector(`input[name="protocol"][value="${pv}"]`); + if (pr) pr.checked = true; + // 回填流式开关(默认流式) + const sv = (p.stream === false) ? 'false' : 'true'; + const sr = form.querySelector(`input[name="stream"][value="${sv}"]`); + if (sr) sr.checked = true; + } + setModelApikeyMode(false); + openModal('add-model-modal'); + applyI18n(); + } catch (ex) { + showChanToast(t('err.modelSave'), ex.message || '', 'err'); + } +} +async function deleteModel(id, name) { + const label = profileLabel(name) || name || ('#' + id); + if (!(await showConfirmDialog({ title: t('common.delete'), message: `${t('confirm.modelDelete')}\n${label}`, okText: t('common.delete'), okKind: 'danger' }))) return; + try { + const res = await bridgeFetch(`/model-profiles/${id}`, { method: 'DELETE', body: {} }); + if (res?.ok === false || res?.error) throw new Error(res.error || t('err.modelDelete')); + const wasActive = state.llmNo === id; + const oldNo = state.llmNo; + state.modelProfiles = normalizeProfiles(res.profiles || []); + if (wasActive) { + const p = state.modelProfiles[0]; + if (p) await selectModel(p.id ?? 0, p.name); + else { state.llmNo = 0; state.modelName = null; updateModelChip(); } + } else if (oldNo > id) { + const p = state.modelProfiles[oldNo - 1]; + if (p) await selectModel(p.id ?? (oldNo - 1), p.name); + } + renderSettingsModels(); + } catch (ex) { + const msg = ex.message || ''; + showChanToast(msg.includes('last profile') ? t('err.modelDeleteLast') : t('err.modelDelete'), msg.includes('last profile') ? '' : msg, 'err'); + } +} +function renderSettingsModels() { + const box = document.getElementById('model-list'); + if (!box) return; + box.innerHTML = ''; + const list = state.modelProfiles || []; + const mixin = list.find(p => p.kind === 'mixin'); + const natives = list.filter(p => p.kind !== 'mixin'); + const byName = new Map(natives.map(p => [p.name, p])); + + // ── 渠道组(自动故障转移):可展开组;本身也可被选为激活模型 ── + if (mixin) { + const gid = mixin.id ?? 0; + const members = mixin.members || []; + const expanded = state.mixinExpanded !== false; // 默认展开 + const group = document.createElement('div'); + group.className = 'model-group'; + const head = document.createElement('label'); + head.className = 'model-row model-row--mixin' + (state.llmNo === gid ? ' sel' : ''); + head.innerHTML = `${GA_ICON(expanded ? 'caretDown' : 'caretRight')}${escapeHtml(t('model.aggregation'))}`; + head.querySelector('[data-act="toggle"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); state.mixinExpanded = !expanded; renderSettingsModels(); }); + head.addEventListener('click', (e) => { if (e.target.closest('[data-act="toggle"]')) return; e.preventDefault(); selectModel(gid, mixin.name); }); + group.appendChild(head); + if (expanded) { + const body = document.createElement('div'); + body.className = 'model-mixin-body'; + if (!members.length) { + const em = document.createElement('div'); + em.className = 'model-mixin-empty'; em.textContent = t('model.emptyMixin'); + body.appendChild(em); + } else { + members.forEach((mName, i) => { + const mp = byName.get(mName); + const row = document.createElement('div'); + row.className = 'model-member'; + row.dataset.member = mName; + row.innerHTML = `${escapeHtml(profileLabel(mName) || mName)}`; + row.querySelector('[data-act="unmix"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); if (mp) removeFromMixin(mp.id ?? 0); }); + body.appendChild(row); + }); + bindMixinDrag(body, members); + } + group.appendChild(body); + } + box.appendChild(group); + } + + // ── 独立模型(聚合渠道组已自带分隔,这里不再单列标题)── + if (!natives.length) { + const empty = document.createElement('div'); + empty.className = 'set-empty'; empty.textContent = t('set.noModels'); + box.appendChild(empty); + } else { + for (const p of natives) { + const id = p.id ?? 0; + const label = profileLabel(p.name) || p.name || ('#' + id); + const row = document.createElement('label'); + row.className = 'model-row' + (state.llmNo === id ? ' sel' : ''); + // 独立列表按钮统一为「加入渠道组」(➕);移除只在渠道组展开区做。 + // 已在渠道组的,按钮仍是「加入」,但点击只提示「已在渠道组中」,并用 is-in 给个淡淡的视觉区分。 + const mixToggle = !mixin ? '' : ``; + row.innerHTML = `${escapeHtml(label)}${mixToggle}`; + row.querySelector('[data-act="edit"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); openEditModelForm(id); }); + row.querySelector('[data-act="delete"]').addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); deleteModel(id, p.name); }); + const addBtn = row.querySelector('[data-act="addmix"]'); + if (addBtn) addBtn.addEventListener('click', (e) => { + e.stopPropagation(); e.preventDefault(); + if (p.inMixin) showToast(t('model.alreadyInMixin')); + else addToMixin(id); + }); + row.addEventListener('click', (e) => { + if (e.target.closest('.model-row-actions')) return; + e.preventDefault(); + selectModel(id, p.name); + }); + box.appendChild(row); + } + } + applyI18n(); +} +function openSettings() { + openModal('settings-modal'); + renderSettingsModels(); + renderLangList(); + applyTheme(theme, { persist: false }); + applyAppearance(appearance, plainUi, { persist: false }); + applyChatFontSize(chatFontSize, { persist: false }); +} +async function loadModelProfiles() { + try { + const res = await window.ga.getModelProfiles(); + const list = res?.profiles || res?.result?.profiles || []; + state.modelProfiles = normalizeProfiles(list); + const active = state.modelProfiles.find(p => p.active) || state.modelProfiles[0]; + if (active) { + state.llmNo = active.id ?? 0; + state.modelName = modelDisplayName(active); + } + updateModelChip(); + renderSettingsModels(); + } catch (_) {} +} +/* ═══════════════ 模型菜单(chat + conductor 共用一份逻辑,各自一个 DOM) ═══════════════ */ +const modelMenu = document.getElementById('model-menu'); +const collabModelMenu = document.getElementById('cdb-model-menu'); +function renderModelMenu(menuEl) { + if (!menuEl) return; + const list = state.modelProfiles || []; + const rows = list.map((p, i) => { + const no = (p.id ?? i); + const isActive = (state.llmNo === no) ? ' active' : ''; + const label = (isActive && p.kind === 'mixin' && state.modelName) ? state.modelName : modelDisplayName(p); + return `
${escapeHtml(label || '')}
`; + }); + menuEl.innerHTML = rows.join(''); + applyI18n(); +} +function openModelMenu(chipEl, menuEl) { + if (!chipEl || !menuEl) return; + if (typeof convMenu !== 'undefined' && convMenu) convMenu.hidden = true; + window.collabComposer?.closeMenu?.(); + closeAllModelMenus(); + renderModelMenu(menuEl); + menuEl.hidden = false; + chipEl.classList.add('open'); + const chipRect = chipEl.getBoundingClientRect(); + const composer = chipEl.closest('.composer'); + if (composer) { + const composerRect = composer.getBoundingClientRect(); + menuEl.style.left = (chipRect.left - composerRect.left) + 'px'; + menuEl.style.bottom = (composerRect.bottom - chipRect.top + 4) + 'px'; + } +} +function closeAllModelMenus() { + if (modelMenu) modelMenu.hidden = true; + if (collabModelMenu) collabModelMenu.hidden = true; + if (modelChip) modelChip.classList.remove('open'); + if (collabModelChip) collabModelChip.classList.remove('open'); +} +function bindModelMenuItemClick(menuEl) { + if (!menuEl) return; + menuEl.addEventListener('click', (e) => { + e.stopPropagation(); + const item = e.target.closest('.ga-menu-item'); + if (!item) return; + const no = parseInt(item.dataset.llmno, 10); + if (Number.isNaN(no)) return; + const p = (state.modelProfiles || []).find(x => (x.id ?? 0) === no); + selectModel(no, (p && p.name) || ''); + closeAllModelMenus(); + }); +} +bindModelMenuItemClick(modelMenu); +bindModelMenuItemClick(collabModelMenu); +if (modelChip) modelChip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + if (modelMenu && !modelMenu.hidden) { closeAllModelMenus(); return; } + openModelMenu(modelChip, modelMenu); +}); +if (collabModelChip) collabModelChip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + if (collabModelMenu && !collabModelMenu.hidden) { closeAllModelMenus(); return; } + openModelMenu(collabModelChip, collabModelMenu); +}); +document.addEventListener('click', (e) => { + if (e.target.closest('#model-menu') || e.target.closest('#model-chip') || + e.target.closest('#cdb-model-menu') || e.target.closest('#cdb-model-chip') || + e.target.closest('#chat-menu') || e.target.closest('#chat-plus-btn') || + e.target.closest('#cdb-menu') || e.target.closest('#cdb-plus-btn')) return; + closeAllModelMenus(); + window.chatComposer?.closeMenu?.(); + window.collabComposer?.closeMenu?.(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeAllModelMenus(); + window.chatComposer?.closeMenu?.(); + window.collabComposer?.closeMenu?.(); + } +}); + +// 主题色板已删除,点击事件不再注册 +const appearanceSeg = document.getElementById('appearance-seg'); +if (appearanceSeg) appearanceSeg.addEventListener('click', (e) => { + const btn = e.target.closest('.appear-card[data-appearance]'); + if (!btn) return; + const isLight = btn.dataset.appearance === 'light'; + applyAppearance(btn.dataset.appearance, isLight && plainUi); +}); +const plainUiSwitch = document.getElementById('plain-ui-switch'); +if (plainUiSwitch) plainUiSwitch.addEventListener('click', () => { + if (appearance === 'light') applyAppearance('light', !plainUi); +}); +async function loadBridgeConfig() { + try { + const res = await window.ga.getConfig(); + const cfg = res?.config || {}; + if (LANGS.includes(cfg.lang)) { + lang = cfg.lang; + applyI18n(); + } + if (cfg.theme != null) applyTheme(cfg.theme, { persist: false }); + if (cfg.appearance) applyAppearance(cfg.appearance, !!cfg.plain, { persist: false }); + if (cfg.fontSize != null) applyChatFontSize(cfg.fontSize, { persist: false }); + if (cfg.llmNo != null && state.modelProfiles.length) { + const p = state.modelProfiles.find(x => (x.id ?? 0) === cfg.llmNo); + if (p) { + state.llmNo = cfg.llmNo; + state.modelName = modelDisplayName(p); + updateModelChip(); + renderSettingsModels(); + } + } + syncBootCache(); + } catch (_) {} +} + +const addModelForm = document.getElementById('add-model-form'); +if (addModelForm) addModelForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const errEl = document.getElementById('add-model-err'); + const fd = new FormData(addModelForm); + const payload = Object.fromEntries(fd.entries()); + const isEdit = editingModelId != null; + if (!payload.apibase?.trim() || !payload.model?.trim()) { + if (errEl) { errEl.textContent = t('err.modelRequired'); errEl.hidden = false; } + return; + } + if (!isEdit && !payload.apikey?.trim()) { + if (errEl) { errEl.textContent = t('err.modelRequired'); errEl.hidden = false; } + return; + } + try { + const res = isEdit + ? await bridgeFetch(`/model-profiles/${editingModelId}`, { method: 'PUT', body: payload }) + : await bridgeFetch('/model-profiles', { method: 'POST', body: payload }); + if (res?.ok === false || res?.error) throw new Error(res.error || t('err.modelSave')); + state.modelProfiles = normalizeProfiles(res.profiles || []); + const pid = isEdit ? editingModelId : (res.profileId ?? state.modelProfiles.at(-1)?.id ?? 0); + const p = state.modelProfiles.find(x => (x.id ?? 0) === pid) || state.modelProfiles.at(-1); + if (p) await selectModel(p.id ?? pid, p.name); + document.getElementById('add-model-modal').hidden = true; + addModelForm.reset(); + editingModelId = null; + if (errEl) errEl.hidden = true; + } catch (ex) { + if (errEl) { errEl.textContent = (ex.message || t('err.modelSave')); errEl.hidden = false; } + } +}); + +/* ═══════════════ 文件上传(图片+任意文件,tuiapp_v2 模式) ═══════════════ */ +const MAX_UPLOAD_FILES = 10; +const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB +const IMG_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i; +const thumbStrip = document.getElementById('thumb-strip'); +const chatPanel = document.querySelector('main.main'); +let activeFileComposer = 'chat'; + +function fileCtx(f) { return f.ctx || 'chat'; } +function filesForCtx(ctx) { return state.pendingFiles.filter(f => fileCtx(f) === ctx); } + +function composerPageEl(ctx) { + const page = ctx === 'collab' ? 'collab' : 'chat'; + return document.querySelector(`.page--chat-ui[data-page="${page}"]`); +} +function composerRootEl(ctx) { + return composerPageEl(ctx)?.querySelector('.composer'); +} +function composerCfg(ctx = activeFileComposer) { + const root = composerRootEl(ctx); + const page = composerPageEl(ctx); + return { + input: root?.querySelector('.composer-inset .input') || null, + strip: root?.querySelector('.thumb-strip') || null, + uploadBtn: null, + imgInput: root?.querySelector('input[type="file"]') || null, + dropZone: ctx === 'collab' ? page : chatPanel, + }; +} + +function renderThumbStrip(ctx = activeFileComposer) { + const cfg = composerCfg(ctx); + if (!cfg.strip) return; + const files = filesForCtx(ctx); + if (files.length === 0) { + cfg.strip.innerHTML = ''; + cfg.strip.hidden = true; + return; + } + cfg.strip.innerHTML = files.map(f => { + if (f.isImage && f.dataUrl) { + return `
`; + } + const name = f.name || 'file'; + const label = name.replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c])); + const sub = fileSubLabel(name).replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c])); + const path = (f.path || '').replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c])); + const dataName = name.replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c])); + return `
${GA_ICON('fileText')}${label}${sub}
`; + }).join(''); + cfg.strip.hidden = false; + applyI18n(); +} + +// 在 ctx 对应输入框(contenteditable)的光标处插入原子 chip(删不进中间,像 @人) +function insertPlaceholderInComposer(file, ctx = activeFileComposer) { + const input = composerCfg(ctx).input; + if (!input) return; + const chip = document.createElement('span'); + chip.className = 'ph-chip'; + chip.setAttribute('contenteditable', 'false'); + chip.dataset.sid = String(file.sid); + chip.dataset.kind = file.isImage ? 'image' : 'file'; + chip.textContent = file.name || 'file'; + input.focus(); + const sel = window.getSelection(); + let range; + if (sel && sel.rangeCount && input.contains(sel.getRangeAt(0).commonAncestorContainer)) { + range = sel.getRangeAt(0); + } else { + range = document.createRange(); range.selectNodeContents(input); range.collapse(false); + } + range.deleteContents(); + range.insertNode(chip); + const sp = document.createTextNode(' '); // chip 后补一个 nbsp,便于继续打字/定位光标 + chip.after(sp); + range.setStartAfter(sp); range.collapse(true); + if (sel) { sel.removeAllRanges(); sel.addRange(range); } + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +// 按 sid 从该文件所属 ctx 的输入框移除 chip(连同紧邻空格) +function removePlaceholderFromComposer(file) { + const input = composerCfg(fileCtx(file)).input; + if (!input) return; + const chip = input.querySelector(`.ph-chip[data-sid="${file.sid}"]`); + if (!chip) return; + const next = chip.nextSibling; + if (next && next.nodeType === 3) next.nodeValue = next.nodeValue.replace(/^[\s ]/, ''); + chip.remove(); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +// 读取 contenteditable 输入框为纯文本:chip → [Image #N]/[File #N],
/
→ 换行 +function readComposerTextFrom(input) { + if (!input) return ''; + const ser = (node, first) => { + if (node.nodeType === 3) return node.nodeValue; + if (node.nodeType !== 1) return ''; + if (node.classList && node.classList.contains('ph-chip')) { + const kind = node.dataset.kind === 'image' ? 'Image' : 'File'; + return `[${kind} #${node.dataset.sid}]`; + } + if (node.tagName === 'BR') return '\n'; + let inner = ''; + node.childNodes.forEach(c => { inner += ser(c, false); }); + return (first ? '' : '\n') + inner; + }; + let out = ''; + input.childNodes.forEach((n, i) => { out += ser(n, i === 0); }); + return out.replace(/ /g, ' '); +} + +function composerText(ctx = activeFileComposer) { + return readComposerTextFrom(composerCfg(ctx).input); +} + +function isImageFile(f) { + return (f && (f.type || '').startsWith('image/')) || IMG_EXT_RE.test(f?.name || ''); +} + +function placeholderFor(file) { + return file.isImage ? `[Image #${file.sid}]` : `[File #${file.sid}]`; +} + +function expandFilePlaceholders(text) { + return text.replace(/\[(Image|File) #(\d+)\]/g, (m, kind, n) => { + const f = state.pendingFiles.find(x => x.sid === Number(n)); + return (f && f.path) ? f.path : ''; // #3 悬空占位符(无对应文件)→ 删掉,不把垃圾发给 agent + }); +} + +function collectUsedFiles(text) { + const used = []; + text.replace(/\[(Image|File) #(\d+)\]/g, (m, kind, n) => { + const f = state.pendingFiles.find(x => x.sid === Number(n)); + if (f) used.push(f); + return m; + }); + return used; +} + +// ── 附件占位符健壮性 ───────────────────────────────────────────── +// 统一移除一个待发附件:出列 + (可选)抹占位符 + 重绘 + 删 bridge 上的文件 +function removePendingFile(sid, { stripPlaceholder = false } = {}) { + const idx = state.pendingFiles.findIndex(f => f.sid === sid); + if (idx < 0) return; + const removed = state.pendingFiles.splice(idx, 1)[0]; + if (stripPlaceholder) removePlaceholderFromComposer(removed); + renderThumbStrip(fileCtx(removed)); + if (removed.path) { + fetch(`${BRIDGE_ORIGIN}/upload`, { + method: 'DELETE', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: removed.path }), + }).catch(() => {}); + } +} +// #1 对账:DOM 里 chip 没了(被原子删除/退格整块删)→ 同步移除附件 + 删磁盘文件 +function reconcilePendingFiles(ctx = activeFileComposer) { + const input = composerCfg(ctx).input; + if (!input) return; + const present = new Set([...input.querySelectorAll('.ph-chip[data-sid]')].map(c => Number(c.dataset.sid))); + for (const f of filesForCtx(ctx).filter(x => !present.has(x.sid))) { + removePendingFile(f.sid, { stripPlaceholder: false }); + } +} + +async function uploadOne(name, dataUrl, sid) { + const res = await fetch(`${BRIDGE_ORIGIN}/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, dataUrl, sid: sid || '' }), + }); + const j = await res.json(); + if (!j.ok) throw new Error(j.error || 'upload failed'); + return j.path; +} + +async function addFiles(fileList) { + const files = Array.from(fileList || []); + if (files.length === 0) return; + let skipped = false; + let emptyHit = false; + const accepted = []; + for (const f of files) { + if (!f || f.size === 0) { emptyHit = true; continue; } + if (f.size > MAX_UPLOAD_BYTES) { skipped = true; continue; } + if (state.pendingFiles.length + accepted.length >= MAX_UPLOAD_FILES) { skipped = true; break; } + accepted.push(f); + } + if (emptyHit) showChanToast(t('upload.empty'), '', 'err'); + if (accepted.length === 0) { + if (skipped) showChanToast(t('upload.tooLarge'), '', 'err'); + return; + } + const ctx = activeFileComposer; + let uploadSid = ''; + if (ctx === 'collab') { + uploadSid = 'collab'; + } else { + let upSess = activeSess(); + if (!upSess) { await newSession(); upSess = activeSess(); } + if (upSess && !upSess.bridgeSessionId) { try { await ensureBridgeSession(upSess); } catch (_) {} } + uploadSid = (upSess && upSess.bridgeSessionId) || ''; + } + for (const f of accepted) { + try { + const dataUrl = await new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(String(r.result || '')); + r.onerror = () => reject(r.error); + r.readAsDataURL(f); + }); + const path = await uploadOne(f.name || 'file', dataUrl, uploadSid); + state.fileSeq += 1; + const sid = state.fileSeq; + const isImage = isImageFile(f); + const entry = { + sid, name: f.name || 'file', isImage, path, + dataUrl: isImage ? dataUrl : '', + ctx, + }; + state.pendingFiles.push(entry); + insertPlaceholderInComposer(entry, ctx); + renderThumbStrip(ctx); + } catch (e) { + showChanToast(t('upload.failed'), e.message || String(e), 'err'); + } + } + if (skipped) showChanToast(t('upload.tooLarge'), '', 'err'); +} + +function handleThumbStripClick(e, ctx) { + const x = e.target.closest('.x'); + if (x) { + const sid = Number(x.dataset.sid); + const idx = state.pendingFiles.findIndex(f => f.sid === sid && fileCtx(f) === ctx); + if (idx >= 0) { + const removed = state.pendingFiles[idx]; + state.pendingFiles.splice(idx, 1); + removePlaceholderFromComposer(removed); + renderThumbStrip(ctx); + if (removed.path) { + fetch(`${BRIDGE_ORIGIN}/upload`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: removed.path }), + }).catch(() => {}); + } + } + return; + } + const fileChip = e.target.closest('.file-chip.pending'); + if (fileChip) { + const path = fileChip.getAttribute('data-path'); + const name = fileChip.getAttribute('data-name'); + if (path) openUploadFile(path, name); + return; + } + const img = e.target.closest('img'); + if (img && img.src) openLightbox(img.src); +} + +function bindComposerUpload(ctx) { + const cfg = composerCfg(ctx); + if (!cfg.input || cfg.input.dataset.gaUploadBound) return; + cfg.input.dataset.gaUploadBound = ctx; + + if (cfg.uploadBtn && !cfg.uploadBtn.dataset.bound) { + cfg.uploadBtn.dataset.bound = '1'; + cfg.uploadBtn.addEventListener('click', (e) => { + e.preventDefault(); + activeFileComposer = ctx; + cfg.imgInput?.click(); + }); + } + if (cfg.imgInput && !cfg.imgInput.dataset.bound) { + cfg.imgInput.dataset.bound = '1'; + cfg.imgInput.addEventListener('change', () => { + activeFileComposer = ctx; + addFiles(cfg.imgInput.files); + cfg.imgInput.value = ''; + }); + } + cfg.strip?.addEventListener('click', (e) => handleThumbStripClick(e, ctx)); + cfg.input.addEventListener('paste', (e) => { + activeFileComposer = ctx; + const cd = e.clipboardData || window.clipboardData; + const items = cd && cd.items; + const files = []; + if (items) for (const it of items) { if (it.kind === 'file') { const f = it.getAsFile(); if (f) files.push(f); } } + if (files.length) { e.preventDefault(); addFiles(files); return; } + // 富文本粘贴 → 强制纯文本(contenteditable 默认会粘 HTML,会污染输入框) + e.preventDefault(); + const text = cd ? cd.getData('text/plain').replace(/\r\n/g, '\n') : ''; + if (!text) return; + // Hard-limit: only insert what fits within maxLen + const maxLen = 20000; + const curLen = cfg.input.textContent.length; + const sel = window.getSelection(); + const selLen = (sel.rangeCount && cfg.input.contains(sel.anchorNode)) ? sel.toString().length : 0; + const remaining = maxLen - curLen + selLen; + if (remaining <= 0) { showChanToast(t('err.charLimit').replace('{n}', maxLen), '', 'err'); return; } + const insert = text.slice(0, remaining); + document.execCommand('insertText', false, insert); + if (text.length > remaining) showChanToast(t('err.charLimit').replace('{n}', maxLen), '', 'err'); + }); + cfg.input.addEventListener('input', () => { + activeFileComposer = ctx; + // 内容清空后浏览器可能残留
,抹掉以便 :empty 占位提示生效 + if (!cfg.input.textContent.trim() && !cfg.input.querySelector('.ph-chip')) cfg.input.innerHTML = ''; + reconcilePendingFiles(ctx); // chip 被删 → 同步清理附件 + 删磁盘文件 + }); + const zone = cfg.dropZone; + const dropKey = `dropBound_${ctx}`; + if (!zone || zone.dataset[dropKey]) return; + zone.dataset[dropKey] = '1'; + let dragDepth = 0; + const hasFiles = (e) => { + const types = e.dataTransfer && e.dataTransfer.types; + if (!types) return false; + for (let i = 0; i < types.length; i += 1) { + if (types[i] === 'Files') return true; + } + return false; + }; + zone.addEventListener('dragenter', (e) => { + if (!hasFiles(e)) return; + e.preventDefault(); + activeFileComposer = ctx; + dragDepth += 1; + zone.classList.add('dragover'); + zone.dataset.dropHint = t('upload.dropHint'); + }); + zone.addEventListener('dragover', (e) => { + if (!hasFiles(e)) return; + e.preventDefault(); + activeFileComposer = ctx; + e.dataTransfer.dropEffect = 'copy'; + }); + zone.addEventListener('dragleave', (e) => { + if (!hasFiles(e)) return; + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) zone.classList.remove('dragover'); + }); + zone.addEventListener('drop', (e) => { + if (!hasFiles(e)) return; + e.preventDefault(); + dragDepth = 0; + zone.classList.remove('dragover'); + activeFileComposer = ctx; + addFiles(e.dataTransfer.files); + }); +} + +bindComposerUpload('chat'); +bindComposerUpload('collab'); + +Object.assign(window, { + gaSetActiveFileComposer: ctx => { activeFileComposer = ctx === 'collab' ? 'collab' : 'chat'; }, + gaPageStatusBar: pageStatusBar, + gaExpandFilePlaceholders: expandFilePlaceholders, + gaRenderMsgChips: renderMsgTextWithChips, + gaCollectUsedFiles: collectUsedFiles, + gaComposerText: composerText, + gaClearUsedPendingFiles: text => removeUsedPendingFiles(collectUsedFiles(text)), + gaFileSubLabel: fileSubLabel, + gaMsgNode: msgNode, + gaCollabItemToMsg: collabItemToMsg, + gaPostRenderEnhance: postRenderEnhance, + gaEscapeHtml: escapeHtml, +}); + +if (chatPanel) { + const blockFileDrop = e => { + const types = e.dataTransfer?.types; + if (!types) return; + for (let i = 0; i < types.length; i += 1) if (types[i] === 'Files') { e.preventDefault(); return; } + }; + window.addEventListener('dragover', blockFileDrop); + window.addEventListener('drop', blockFileDrop); +} + +/* ═══════════════ bridge 事件 ═══════════════ */ +window.ga.onBridgeReady(async () => { + state.bridgeReady = true; + syncPlanPollTimer(); + refreshStatusLabel(); + if (!state.activeId) { refreshEmptyState(null); } + await loadModelProfiles(); + await loadBridgeConfig(); + if (isServicesPageActive()) renderChannelList(gaServiceStore.list()); + const sess = activeSess(); + if (sess && sessionNeedsHydrate(sess)) { + await runSessionHydrate(sess); + } else if (sess) planPoll(sess); + delete document.documentElement.dataset.bootHasSessions; + if (sess) refreshEmptyState(sess); +}); +setTimeout(() => { delete document.documentElement.dataset.bootHasSessions; }, 3000); +window.ga.onBridgeNotification((msg) => { + if (msg && msg.type === 'session-state') { + for (const sess of state.sessions.values()) { + if (sess.bridgeSessionId === msg.sessionId) { + if (msg.status === 'running' || msg.state === 'running') pollSession(sess); + if (msg.state === 'idle' || msg.status === 'idle') tokPollBridge(); + renderSessionList(); + break; + } + } + } +}); +window.ga.onBridgeError((err) => { console.warn('[bridge error]', err); }); +window.ga.onBridgeClosed(() => { + state.bridgeReady = false; + syncPlanPollTimer(); + const s = activeSess(); + if (s) applyPlanPayload(s, null); + chatStatus.setDisconnected(); +}); + +/* ═══════════════ Token 用量页 ═══════════════ */ +const tokTbody = document.getElementById('tok-tbody'); +const tokTable = document.getElementById('tok-table'); +const tokPager = document.getElementById('tok-pager'); +const tokSince = document.getElementById('tok-since'); +const tokUntil = document.getElementById('tok-until'); +const tokTotalN = document.getElementById('tok-total-n'); +const tokTodayN = document.getElementById('tok-today-n'); +const tokCostN = document.getElementById('tok-cost-n'); +const TOK_PER_PAGE = 15; +let _tokPage = 0; +let _tokHistory = []; +let _tokLastSnap = {}; + +// Model price table: $/M tokens [input, output] +const MODEL_PRICES = { + 'gpt-5.4':[2.50,15],'gpt-5':[1.25,10],'gpt-5-mini':[0.25,2],'gpt-4o':[2.50,10],'gpt-4o-mini':[0.15,0.60], + 'gpt-4.1':[2,8],'gpt-4.1-mini':[0.40,1.60],'gpt-4.1-nano':[0.10,0.40],'o4-mini':[0.55,2.20], + 'claude-opus-4-8':[5,25],'claude-opus-4-7':[5,25],'claude-opus-4-6':[5,25],'claude-sonnet-4-6':[3,15],'claude-sonnet-4-5':[3,15],'claude-haiku-4-5':[1,5], + 'deepseek-v4':[0.14,0.28],'deepseek-v4-pro':[0.435,0.87],'deepseek-chat':[0.14,0.28],'deepseek-reasoner':[0.55,2.19], + 'glm-5.1':[0.50,0.50],'minimax-m2.7':[0.50,0.50],'kimi-for-coding':[0.50,2], +}; +const CNY_RATE = 7.2; +function estCost(inp, out, model, cacheRead, cacheCreate) { + let p = [3,15]; + if (model) { const m = model.toLowerCase().replace(/\[.*\]/,''); p = MODEL_PRICES[m] || Object.entries(MODEL_PRICES).find(([k])=>m.includes(k))?.[1] || p; } + const isClaudeOrDS = model && /claude|deepseek/i.test(model); + const cacheReadRate = isClaudeOrDS ? 0.1 : 0.5; + const cacheWriteRate = isClaudeOrDS ? 1.25 : 1.0; + const cost = (inp*p[0] + out*p[1] + (cacheRead||0)*p[0]*cacheReadRate + (cacheCreate||0)*p[0]*cacheWriteRate) / 1e6 * CNY_RATE; + return cost.toFixed(2); +} +function fmtTok(n) { return n>=1e6?(n/1e6).toFixed(2)+'M':n>=1e3?(n/1e3).toFixed(1)+'k':String(n); } +function fmtTime(ts) { return new Date(ts*1000).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); } +function modelPriceTip(model) { + if (!model) return ''; + const m = model.toLowerCase().replace(/\[.*\]/,''); + const entry = MODEL_PRICES[m] || Object.entries(MODEL_PRICES).find(([k])=>m.includes(k))?.[1]; + const known = !!entry; + const p = entry || [3,15]; + const isClaudeOrDS = /claude|deepseek/i.test(m); + const cacheReadRate = isClaudeOrDS ? 0.1 : 0.5; + const cacheWriteRate = isClaudeOrDS ? 1.25 : 1.0; + const lines = []; + if (!known) lines.push(t('tok.pricingUnknown')); + lines.push(t('tok.priceInput') + p[0] + ' /M'); + lines.push(t('tok.priceOutput') + p[1] + ' /M'); + lines.push(t('tok.priceCacheW') + (p[0] * cacheWriteRate).toFixed(2) + ' /M'); + lines.push(t('tok.priceCacheR') + (p[0] * cacheReadRate).toFixed(2) + ' /M'); + return lines.join('\n'); +} + +function tokLoadHistory() { return _tokHistory; } +function tokSaveHistory(h) { + _tokHistory = h; + fetch(`${BRIDGE_ORIGIN}/token-history`, { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({history:h, snap:_tokLastSnap, conductorHist:_condHist, conductorLast:_condLast}) + }).catch(()=>{}); +} + +let _tokPolling = false; +async function tokPollBridge() { + if (_tokPolling) return; + _tokPolling = true; + try { + if (!_tokHistory.length) { + const stored = await bridgeFetch('/token-history'); + if (stored.history?.length) _tokHistory = stored.history; + if (stored.snap) _tokLastSnap = stored.snap; + if (stored.conductorHist) _condHist = stored.conductorHist; + if (stored.conductorLast) _condLast = stored.conductorLast; + } + const data = await bridgeFetch('/token-stats'); + const history = tokLoadHistory(); + for (const r of (data.records||[])) { + const key = r.thread; + const sid = key.replace('GA-',''); + const sess = [...state.sessions.values()].find(s=>s.bridgeSessionId===sid); + if (sess && rt(sess).busy) continue; + const prev = _tokLastSnap[key] || {input:0,output:0,cacheCreate:0,cacheRead:0}; + let di = r.input-prev.input, do_ = r.output-prev.output, dc = r.cacheCreate-prev.cacheCreate, dr = r.cacheRead-prev.cacheRead; + if (di<0||do_<0||dc<0||dr<0) { di = r.input; do_ = r.output; dc = r.cacheCreate; dr = r.cacheRead; } + if (di>0||do_>0||dc>0||dr>0) { + const title = sess ? displayTitle(sess) : sid; + history.push({sessionId:sid, title:title, input:di, output:do_, cacheCreate:dc, cacheRead:dr, model:r.model||'', ts:Date.now()/1000}); + if(sess?.title) history.forEach(h=>{if(h.sessionId===sid&&(!h.title||h.title===sid))h.title=sess.title;}); + } + _tokLastSnap[key] = {input:r.input, output:r.output, cacheCreate:r.cacheCreate, cacheRead:r.cacheRead}; + } + tokSaveHistory(history); + } catch(_) {} + _tokPolling = false; +} + +function tokGetFiltered() { + let records = tokLoadHistory(); + const parseD = v => v ? new Date(v.replace(/\s+/,'T')).getTime()/1000 : 0; + const since = parseD(tokSince?.value); + const until = parseD(tokUntil?.value); + if (since) records = records.filter(r=>r.ts>=since); + if (until) records = records.filter(r=>r.ts<=until); + return records; +} + +function tokRenderStats(filtered, all) { + let total=0, totalInput=0, totalCacheRead=0, totalCacheCreate=0; + filtered.forEach(r=>{total+=(r.input||0)+(r.output||0)+(r.cacheRead||0)+(r.cacheCreate||0); totalInput+=(r.input||0); totalCacheRead+=(r.cacheRead||0); totalCacheCreate+=(r.cacheCreate||0);}); + if(tokTotalN) tokTotalN.textContent=fmtTok(total); + const cacheBase = totalInput + totalCacheRead + totalCacheCreate; + if(tokCostN) tokCostN.textContent= cacheBase > 0 ? (totalCacheRead / cacheBase * 100).toFixed(1) + '%' : '0%'; + const todayStart=new Date(); todayStart.setHours(0,0,0,0); const todayTs=todayStart.getTime()/1000; + let todayT=0; all.filter(r=>r.ts>=todayTs).forEach(r=>{todayT+=(r.input||0)+(r.output||0)+(r.cacheRead||0)+(r.cacheCreate||0);}); + if(tokTodayN) tokTodayN.textContent=fmtTok(todayT); +} + +function tokRenderTable(records) { + if(!tokTbody) return; + const bySession=new Map(); + for(const r of records){ + const k=r.sessionId||'?'; + const ss= r._conductor ? null : [...state.sessions.values()].find(s=>s.bridgeSessionId===k); + let title = ss ? displayTitle(ss) : (r.title||k); + const deleted = r._conductor ? !!r._killed : !ss; + if(!bySession.has(k)) bySession.set(k,{title:title,deleted:deleted,input:0,output:0,cacheCreate:0,cacheRead:0,lastTs:0,prompts:[]}); + const s=bySession.get(k); s.input+=r.input||0; s.output+=r.output||0; s.cacheCreate+=r.cacheCreate||0; s.cacheRead+=r.cacheRead||0; + if(r.ts>s.lastTs){s.lastTs=r.ts; s.title=title;} s.prompts.push(r); + } + tokTbody.innerHTML=''; + if(bySession.size===0){tokTbody.innerHTML=`${t('tok.noData')}`;if(tokPager)tokPager.innerHTML='';return;} + const sorted=[...bySession.values()].sort((a,b)=>b.lastTs-a.lastTs); + const totalPages=Math.ceil(sorted.length/TOK_PER_PAGE); + if(_tokPage>=totalPages)_tokPage=totalPages-1; + const pageItems=sorted.slice(_tokPage*TOK_PER_PAGE,(_tokPage+1)*TOK_PER_PAGE); + for(const s of pageItems){ + const sCacheBase = s.input + s.cacheRead + s.cacheCreate; + const sCacheRate = sCacheBase > 0 ? (s.cacheRead / sCacheBase * 100).toFixed(1) + '%' : '0%'; + const tr=document.createElement('tr'); tr.className='tok-row-session'; + tr.innerHTML=`${escapeHtml(s.title)}${s.deleted?''+t('tok.deleted')+'':''}${fmtTok(s.input)}${fmtTok(s.output)}${fmtTok(s.cacheCreate)}${fmtTok(s.cacheRead)}${sCacheRate}`; + tokTbody.appendChild(tr); + const details=[]; s.prompts.sort((a,b)=>b.ts-a.ts); + for(const p of s.prompts){ + const dr=document.createElement('tr'); dr.className='tok-detail'; dr.hidden=true; + const modelHtml = p.model ? ` · ${escapeHtml(p.model)}` : ''; + const pCacheBase = (p.input||0) + (p.cacheRead||0) + (p.cacheCreate||0); + const pCacheRate = pCacheBase > 0 ? ((p.cacheRead||0) / pCacheBase * 100).toFixed(1) + '%' : '0%'; + dr.innerHTML=`${fmtTime(p.ts)}${modelHtml}${fmtTok(p.input||0)}${fmtTok(p.output||0)}${fmtTok(p.cacheCreate||0)}${fmtTok(p.cacheRead||0)}${pCacheRate}`; + tokTbody.appendChild(dr); details.push(dr); + } + tr.addEventListener('click',()=>{const o=tr.classList.toggle('open');details.forEach(d=>d.hidden=!o);}); + } + if(tokPager){renderTokPager(tokPager, totalPages, _tokPage, p => { _tokPage = p; tokRenderTable(records); });} +} + +/* 分页按钮:首页/末页 + 当前页前后各 2 + 省略号,最多渲染 ~9 个 DOM, + 1000 页和 10 页都长一样,不会一行排开拖死浏览器。 */ +function renderTokPager(host, totalPages, currentPage, onJump) { + host.innerHTML = ''; + if (totalPages <= 1) return; + const makeBtn = (label, page, opts = {}) => { + const b = document.createElement('button'); + if (opts.svg) b.innerHTML = label; + else b.textContent = label; + if (opts.active) b.classList.add('active'); + if (opts.arrow) b.classList.add('tok-pager-arrow'); + if (opts.disabled) b.disabled = true; + if (!opts.disabled) b.addEventListener('click', () => onJump(page)); + return b; + }; + const makeEllipsis = () => { + const s = document.createElement('span'); + s.className = 'tok-pager-gap'; + s.textContent = '…'; + return s; + }; + // 收集页码:1 / 当前-2..当前+2 / 末页,去重并填省略号 + const pages = new Set([0, totalPages - 1]); + for (let i = Math.max(0, currentPage - 2); i <= Math.min(totalPages - 1, currentPage + 2); i++) pages.add(i); + const sorted = [...pages].sort((a, b) => a - b); + // 首尾箭头用 phosphor 图标(跟侧栏 .chev 同款),不再用 Unicode 字符 + host.appendChild(makeBtn(GA_ICON('caretLeft'), currentPage - 1, { svg: true, arrow: true, disabled: currentPage === 0 })); + let prev = -1; + for (const p of sorted) { + if (prev >= 0 && p - prev > 1) host.appendChild(makeEllipsis()); + host.appendChild(makeBtn(String(p + 1), p, { active: p === currentPage })); + prev = p; + } + host.appendChild(makeBtn(GA_ICON('caretRight'), currentPage + 1, { svg: true, arrow: true, disabled: currentPage === totalPages - 1 })); +} + +async function loadTokenPage(){await tokPollBridge();const f=tokGetFiltered();const all=tokLoadHistory();tokRenderStats(f,all);tokRenderTable(f);} + +const _COND_HIST_KEY = 'conductor_token_hist'; +const _COND_LAST_KEY = 'conductor_token_last'; +const _condZero = {input:0,output:0,cacheCreate:0,cacheRead:0,cost:0}; +let _condHist = null, _condLast = null; +function _condLoadHist() { return _condHist || {..._condZero}; } +function _condLoadLast() { return _condLast; } +function _condSave(hist, last) { + _condHist = hist; _condLast = last; + fetch(`${BRIDGE_ORIGIN}/token-history`, { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({history:_tokHistory, snap:_tokLastSnap, conductorHist:hist, conductorLast:last}) + }).catch(()=>{}); +} + +/* ─── Token tab switching ─── */ +let _tokTab = 'chat'; +const tokTabs = document.getElementById('tok-tabs'); +const tokFilter = document.querySelector('.tok-filter'); +const tokStatRow = document.querySelector('.page[data-page="token"] .stat-row'); +if (tokTabs) tokTabs.addEventListener('click', e => { + const btn = e.target.closest('.tok-tab'); + if (!btn || btn.classList.contains('active')) return; + tokTabs.querySelectorAll('.tok-tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + _tokTab = btn.dataset.tab; + _tokPage = 0; + if (_tokTab === 'conductor') { if (tokFilter) tokFilter.style.display = 'none'; if (tokStatRow) tokStatRow.style.display = 'none'; if (tokTable) tokTable.classList.add('tok-table--conductor'); loadConductorTokens(); } + else { if (tokFilter) tokFilter.style.display = ''; if (tokStatRow) tokStatRow.style.display = ''; if (tokTable) tokTable.classList.remove('tok-table--conductor'); loadTokenPage(); } +}); + +async function loadConductorTokens() { + let curIn = 0, curOut = 0, curCc = 0, curCr = 0, curCost = 0; + let fetchOk = false; + try { + const data = await (await fetch(`${CONDUCTOR_ORIGIN}/token-stats`)).json(); + const recs = (data.records || []).filter(r => r.thread === 'conductor-agent' || r.thread.startsWith('subagent-')); + for (const r of recs) { + curIn += r.input || 0; curOut += r.output || 0; curCc += r.cacheCreate || 0; curCr += r.cacheRead || 0; + curCost += parseFloat(estCost(r.input || 0, r.output || 0, r.model || '', r.cacheRead || 0, r.cacheCreate || 0)); + } + fetchOk = true; + } catch (_) { + if (tokTbody) tokTbody.innerHTML = `${t('tok.condOffline')}`; + return; + } + const hist = _condLoadHist(); + const last = _condLoadLast(); + if (fetchOk && last && (curIn < last.input || curOut < last.output)) { + hist.input += last.input; hist.output += last.output; hist.cacheCreate += last.cacheCreate; hist.cacheRead += last.cacheRead; hist.cost += last.cost; + } + if (fetchOk) _condSave(hist, {input:curIn, output:curOut, cacheCreate:curCc, cacheRead:curCr, cost:curCost}); + const hIn = hist.input + curIn, hOut = hist.output + curOut, hCc = hist.cacheCreate + curCc, hCr = hist.cacheRead + curCr, hCost = hist.cost + curCost; + if (!tokTbody) return; + const tip = t('tok.condTip'); + const _ci = GA_ICON('gitFork', 'tok-cond-ico'); + const hCacheBase = hIn + hCr + hCc; + const hCacheRate = hCacheBase > 0 ? (hCr / hCacheBase * 100).toFixed(1) + '%' : '0%'; + const curCacheBase = curIn + curCr + curCc; + const curCacheRate = curCacheBase > 0 ? (curCr / curCacheBase * 100).toFixed(1) + '%' : '0%'; + tokTbody.innerHTML = `${_ci}${t('tok.condTotal')}${fmtTok(hIn)}${fmtTok(hOut)}${fmtTok(hCc)}${fmtTok(hCr)}${hCacheRate}${_ci}${t('tok.condCurrent')}${fmtTok(curIn)}${fmtTok(curOut)}${fmtTok(curCc)}${fmtTok(curCr)}${curCacheRate}`; + const pager = document.getElementById('tok-pager'); + if (pager) pager.innerHTML = ''; +} + +/* Flatpickr 初始化 */ +const _fpOpts = { enableTime:true, time_24hr:true, dateFormat:'Y-m-d H:i', locale:window.flatpickr?.l10ns?.[document.documentElement.lang==='en'?'default':'zh']||'default', allowInput:false, onChange(){ _tokPage=0; loadTokenPage(); } }; +const fpSince = tokSince ? flatpickr(tokSince, _fpOpts) : null; +const fpUntil = tokUntil ? flatpickr(tokUntil, _fpOpts) : null; +const tokResetBtn=document.getElementById('tok-reset'); +if(tokResetBtn)tokResetBtn.addEventListener('click',()=>{if(fpSince)fpSince.clear();if(fpUntil)fpUntil.clear();_tokPage=0;loadTokenPage();}); + +/* ─── Token trend chart ─── */ +nav.addEventListener('click',(e)=>{const item=e.target.closest('.nav-item');if(item&&item.dataset.page==='token'){if(_tokTab==='conductor')loadConductorTokens();else loadTokenPage();}if(item&&item.dataset.page==='services')refreshServicesPanel();}); +/* ═══════════════ 自定义预设 ═══════════════ */ +const CP_KEY = 'ga_custom_presets'; +const HB_KEY = 'ga_hidden_builtins'; + +const BUILTIN_PRESETS = [ + { key: 'butler', titleKey: 'preset.butler.t', descKey: 'preset.butler.d', navigate: 'collab', + get iconSvg() { return GA_ICON('gitFork', 'fc-ic'); } }, + { key: 'plan', titleKey: 'preset.plan.t', descKey: 'preset.plan.d', promptKey: 'presetPrompt.plan', + get iconSvg() { return GA_ICON('listChecks', 'fc-ic'); } }, + { key: 'goal', titleKey: 'preset.goal.t', descKey: 'preset.goal.d', promptKey: 'presetPrompt.goal', + get iconSvg() { return GA_ICON('crosshair', 'fc-ic'); } }, + { key: 'autonomous', titleKey: 'preset.autonomous.t', descKey: 'preset.autonomous.d', promptKey: 'presetPrompt.autonomous', + get iconSvg() { return GA_ICON('gridFour', 'fc-ic'); } }, + { key: 'hive', titleKey: 'preset.hive.t', descKey: 'preset.hive.d', promptKey: 'presetPrompt.hive', + get iconSvg() { return GA_ICON('hexagon', 'fc-ic'); } }, + { key: 'review', titleKey: 'preset.review.t', descKey: 'preset.review.d', promptKey: 'presetPrompt.review', + get iconSvg() { return GA_ICON('magnifyingGlass', 'fc-ic'); } }, + { key: 'findwork', titleKey: 'preset.findwork.t', descKey: 'preset.findwork.d', promptKey: 'presetPrompt.findwork', + get iconSvg() { return GA_ICON('robot', 'fc-ic'); } }, + { key: 'mine', titleKey: 'preset.mine.t', descKey: 'preset.mine.d', promptKey: 'presetPrompt.mine', + get iconSvg() { return GA_ICON('star', 'fc-ic'); } }, +]; +const ADD_ICON_SVG = GA_ICON('plus', 'fc-ic'); +// 自定义保存后生成的卡片图标(用户图标,表示"用户自定义的任务")—— 与"添加"卡的 + 区分 +const CUSTOM_ICON_SVG = ''; + +state.customPresets = []; +state.hiddenBuiltins = new Set(); + +function loadCustomPresets() { + try { + const raw = localStorage.getItem(CP_KEY); + const arr = raw ? JSON.parse(raw) : []; + state.customPresets = Array.isArray(arr) ? arr.filter(p => p && p.id && p.title && p.prompt) : []; + } catch { state.customPresets = []; } +} +function saveCustomPresets() { + localStorage.setItem(CP_KEY, JSON.stringify(state.customPresets)); +} +function loadHiddenBuiltins() { + try { + const raw = localStorage.getItem(HB_KEY); + const arr = raw ? JSON.parse(raw) : []; + state.hiddenBuiltins = new Set(Array.isArray(arr) ? arr.filter(k => typeof k === 'string') : []); + } catch { state.hiddenBuiltins = new Set(); } +} +function saveHiddenBuiltins() { + localStorage.setItem(HB_KEY, JSON.stringify([...state.hiddenBuiltins])); +} + +const EDIT_PENCIL_SVG = ''; +function makeCardEl({ kind, dataAttrs, iconSvg, titleText, descText, removable, editable }) { + const card = document.createElement('div'); + card.className = 'fcard ' + kind; + for (const [k, v] of Object.entries(dataAttrs || {})) card.dataset[k] = v; + card.innerHTML = iconSvg; + if (editable) { + const ed = document.createElement('button'); + ed.className = 'fc-edit'; + ed.type = 'button'; + ed.dataset.editId = dataAttrs?.id || ''; + ed.dataset.i18nTitle = 'customPreset.editTitle'; + ed.title = t('customPreset.editTitle'); + ed.innerHTML = EDIT_PENCIL_SVG; + card.appendChild(ed); + } + if (removable) { + const x = document.createElement('button'); + x.className = 'fc-x'; + x.type = 'button'; + x.dataset.removeKind = kind === 'fcard-builtin' ? 'builtin' : 'custom'; + x.dataset.removeId = dataAttrs?.id || dataAttrs?.preset || ''; + x.dataset.i18nTitle = 'customPreset.removeTitle'; + x.title = t('customPreset.removeTitle'); + x.textContent = '×'; + card.appendChild(x); + } + const titleEl = document.createElement('div'); + titleEl.className = 'fc-t'; + titleEl.textContent = titleText; + titleEl.title = titleText; // 截断后悬停看完整标题 + card.appendChild(titleEl); + const descEl = document.createElement('div'); + descEl.className = 'fc-d'; + descEl.textContent = descText; + descEl.title = descText; // 截断后悬停看完整描述 + card.appendChild(descEl); + return card; +} + +function renderAllPresets() { + document.querySelectorAll('.feature-grid').forEach(grid => { + grid.innerHTML = ''; + for (const bp of BUILTIN_PRESETS) { + if (state.hiddenBuiltins.has(bp.key)) continue; + grid.appendChild(makeCardEl({ + kind: 'fcard-builtin', + dataAttrs: { preset: bp.key }, + iconSvg: bp.iconSvg, + titleText: t(bp.titleKey), + descText: t(bp.descKey), + removable: true, + })); + } + for (const cp of state.customPresets) { + grid.appendChild(makeCardEl({ + kind: 'fcard-custom', + dataAttrs: { id: cp.id }, + iconSvg: CUSTOM_ICON_SVG, + titleText: cp.title, + descText: cp.prompt, + removable: true, + editable: true, + })); + } + const addCard = makeCardEl({ + kind: 'add', + dataAttrs: { preset: 'add' }, + iconSvg: ADD_ICON_SVG, + titleText: t('preset.add.t'), + descText: t('preset.add.d'), + removable: false, + }); + grid.appendChild(addCard); + }); + updateRestoreBtnVisibility(); +} + +function addCustomPreset(title, prompt) { + const id = 'cp-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6); + state.customPresets.push({ id, title, prompt }); + saveCustomPresets(); + renderAllPresets(); +} +function updateCustomPreset(id, title, prompt) { + const cp = state.customPresets.find(p => p.id === id); + if (!cp) return; + cp.title = title; + cp.prompt = prompt; + saveCustomPresets(); + renderAllPresets(); +} +function removeCustomPreset(id) { + const idx = state.customPresets.findIndex(p => p.id === id); + if (idx < 0) return; + state.customPresets.splice(idx, 1); + saveCustomPresets(); + renderAllPresets(); +} +function hideBuiltinPreset(key) { + if (!BUILTIN_PRESETS.some(bp => bp.key === key)) return; + state.hiddenBuiltins.add(key); + saveHiddenBuiltins(); + renderAllPresets(); +} +function restoreBuiltinPresets() { + state.hiddenBuiltins.clear(); + saveHiddenBuiltins(); + renderAllPresets(); +} +function updateRestoreBtnVisibility() { + const btn = document.getElementById('preset-restore-btn'); + if (!btn) return; + btn.hidden = state.hiddenBuiltins.size === 0; +} + +const cpModal = document.getElementById('custom-preset-modal'); +const cpTitleInput = document.getElementById('cp-title'); +const cpPromptInput = document.getElementById('cp-prompt'); +const cpSaveBtn = document.getElementById('cp-save'); +const cpError = document.getElementById('cp-error'); +const cpModalTitle = cpModal?.querySelector('.modal-title'); +let cpEditId = null; // null=新建模式; 否则为正在编辑的自定义预设 id +function clearCpFieldHints() { + cpModal?.querySelectorAll('.field-limit-hint').forEach(h => { h.style.display = 'none'; }); +} +function resetCustomPresetForm() { + cpEditId = null; + if (cpModalTitle) cpModalTitle.textContent = t('modal.customPreset'); + if (cpTitleInput) cpTitleInput.value = ''; + if (cpPromptInput) cpPromptInput.value = ''; + if (cpError) { cpError.hidden = true; cpError.textContent = ''; } + clearCpFieldHints(); + setTimeout(() => { if (cpTitleInput) cpTitleInput.focus(); }, 0); +} +function openCustomPresetEditor(cp) { + closeModals(); + openModal('custom-preset-modal'); + cpEditId = cp.id; + if (cpModalTitle) cpModalTitle.textContent = t('modal.editCustomPreset'); + if (cpTitleInput) cpTitleInput.value = cp.title; + if (cpPromptInput) cpPromptInput.value = cp.prompt; + if (cpError) { cpError.hidden = true; cpError.textContent = ''; } + clearCpFieldHints(); + setTimeout(() => { if (cpTitleInput) cpTitleInput.focus(); }, 0); +} +if (cpSaveBtn) cpSaveBtn.addEventListener('click', () => { + const title = (cpTitleInput?.value || '').trim(); + const prompt = (cpPromptInput?.value || '').trim(); + if (!title || !prompt) { + if (cpError) { cpError.textContent = t('customPreset.empty'); cpError.hidden = false; } + return; + } + if (cpEditId) updateCustomPreset(cpEditId, title, prompt); + else addCustomPreset(title, prompt); + cpEditId = null; + if (cpModal) cpModal.hidden = true; +}); + +const restoreBtn = document.getElementById('preset-restore-btn'); +if (restoreBtn) restoreBtn.addEventListener('click', () => { restoreBuiltinPresets(); }); + + +/* ═══════════════ 图片预览 lightbox ═══════════════ */ +const lightbox = document.getElementById('lightbox'); +const lightboxImg = document.getElementById('lightbox-img'); +function openLightbox(src) { + if (!lightbox || !lightboxImg || !src) return; + lightboxImg.src = src; + lightbox.hidden = false; +} +function closeLightbox() { + if (!lightbox || !lightboxImg) return; + lightbox.hidden = true; + lightboxImg.src = ''; +} +if (lightbox) { + lightbox.addEventListener('click', (e) => { + if (e.target.closest('[data-close]')) closeLightbox(); + }); +} +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && lightbox && !lightbox.hidden) closeLightbox(); +}); +if (msgArea) { + msgArea.addEventListener('click', (e) => { + const img = e.target.closest('.user-imgs img'); + if (img && img.src) { openLightbox(img.src); return; } + const fileChip = e.target.closest('.user-files .file-chip'); + if (fileChip) { + const path = fileChip.getAttribute('data-path'); + const name = fileChip.getAttribute('data-name'); + if (path) openUploadFile(path, name); + } + }); +} + +function uploadRawUrl(path, download) { + return `${BRIDGE_ORIGIN}/upload/raw?path=${encodeURIComponent(path || '')}${download ? '&download=1' : ''}`; +} +function bridgeIsLocal() { + return location.hostname === '127.0.0.1' || location.hostname === 'localhost'; +} +async function openUploadFile(path, name) { + // 远程访问:浏览器无法调起 bridge 那台/本机的系统程序,降级为下载到本机 + if (!bridgeIsLocal()) { + const a = document.createElement('a'); + a.href = uploadRawUrl(path, true); + a.download = name || ''; + document.body.appendChild(a); a.click(); a.remove(); + return; + } + // 本地:bridge 与你同机,调系统默认程序打开 / 在文件夹显示 + const mode = isPreviewableByName(name || path) ? 'open' : 'reveal'; + try { + const res = await fetch(`${BRIDGE_ORIGIN}/path/open`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'upload', path, mode }), + }); + const j = await res.json(); + if (!j.ok) throw new Error(j.error || 'open failed'); + } catch (e) { + showChanToast(t('file.openFailed'), e.message || String(e), 'err'); + } +} + +const PREVIEWABLE_EXTS = new Set([ + 'pdf', + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'heic', 'tiff', + 'txt', 'md', 'log', 'json', 'yaml', 'yml', 'xml', 'csv', 'tsv', 'ini', 'toml', 'env', 'rtf', + 'py', 'js', 'ts', 'tsx', 'jsx', 'java', 'c', 'cpp', 'h', 'hpp', 'rs', 'go', 'rb', 'php', 'sh', 'bash', 'zsh', 'fish', 'lua', 'pl', 'r', 'scala', 'kt', 'swift', + 'html', 'htm', 'css', 'scss', 'sass', 'less', 'vue', 'svelte', 'sql', + 'doc', 'docx', 'pages', 'odt', + 'xls', 'xlsx', 'numbers', 'ods', + 'ppt', 'pptx', 'key', 'odp', + 'mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', + 'mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv', +]); +function isPreviewableByName(name) { + const m = String(name || '').match(/\.([^./\\]+)$/); + if (!m) return false; + return PREVIEWABLE_EXTS.has(m[1].toLowerCase()); +} + +/* ═══════════════ 后台服务页 Tab(消息通道 / 状态面板) ═══════════════ */ +let _svcTab = 'channels'; +const svcTabsEl = document.getElementById('svc-tabs'); + +function isServicesPageActive() { + return !!document.querySelector('.page[data-page="services"].active'); +} +function isSvcTab(tab) { + return isServicesPageActive() && _svcTab === tab; +} +function setSvcTab(tab) { + if (!tab || tab === _svcTab) return; + _svcTab = tab; + svcTabsEl?.querySelectorAll('.svc-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === tab)); + document.querySelectorAll('[data-svc-panel]').forEach((p) => p.classList.toggle('active', p.dataset.svcPanel === tab)); + if (tab === 'channels') renderChannelList(gaServiceStore.list()); + else loadStatusPanel(); +} +function refreshServicesPanel() { + if (!isServicesPageActive()) return; + if (_svcTab === 'channels') renderChannelList(gaServiceStore.list()); + else loadStatusPanel(); +} +if (svcTabsEl) { + svcTabsEl.addEventListener('click', (e) => { + const btn = e.target.closest('.svc-tab'); + if (!btn) return; + setSvcTab(btn.dataset.tab); + }); +} + +/* ═══════════════ 消息通道(复用 gaServiceStore + WS 同步) ═══════════════ */ +const CHAN_ICON = GA_ICON('chatTeardropText', 'lr-ic'); +const CHAN_FILE_LABELS = { + 'qqapp.py': 'ch.qq', + 'wechatapp.py': 'ch.wechat', + 'wecomapp.py': 'ch.wecom', + 'dingtalkapp.py': 'ch.dingtalk', + 'tgapp.py': 'ch.telegram', + 'dcapp.py': 'ch.discord', + 'fsapp.py': 'ch.lark', +}; +const chanListEl = document.getElementById('chan-list'); +const chanEmptyEl = document.getElementById('chan-empty'); +const chanLogModal = document.getElementById('chan-log-modal'); +const chanLogPre = document.getElementById('chan-log-pre'); +const chanLogTitle = document.getElementById('chan-log-title'); +const chanConfigModal = document.getElementById('chan-config-modal'); +const chanConfigTitle = document.getElementById('chan-config-title'); +const chanConfigEditor = document.getElementById('chan-config-editor'); +const chanConfigSave = document.getElementById('chan-config-save'); +let _chanLogId = null; +let _chanBusy = false; +let _chanToastTimer = null; + +function getToastRoot() { + let root = document.getElementById('toast-root'); + if (!root) { + root = document.createElement('div'); + root.id = 'toast-root'; + root.className = 'toast-root'; + root.setAttribute('aria-live', 'polite'); + document.body.appendChild(root); + } + return root; +} + +function showChanToast(title, detail, kind) { + if (!title) return; + const root = getToastRoot(); + if (_chanToastTimer) clearTimeout(_chanToastTimer); + root.innerHTML = ''; + const el = document.createElement('div'); + el.className = `toast toast-${kind === 'err' ? 'err' : kind === 'info' ? 'info' : 'ok'}`; + const tEl = document.createElement('span'); + tEl.className = 'toast-title'; + tEl.textContent = title; + el.appendChild(tEl); + if (detail) { + const dEl = document.createElement('span'); + dEl.className = 'toast-detail'; + dEl.textContent = detail; + el.appendChild(dEl); + } + root.appendChild(el); + const show = () => el.classList.add('show'); + requestAnimationFrame(show); + setTimeout(show, 16); + _chanToastTimer = setTimeout(() => { + el.classList.remove('show'); + setTimeout(() => el.remove(), 300); + }, 3000); +} + +/* ── Input length validation ── */ +(function initInputLimits() { + let _toastTimer = null; + + // msgKey 默认用会截断的文案;硬上限输入框传 'err.charLimitReached'(只提醒不提截断) + function limitToast(maxLen, msgKey = 'err.charLimit') { + clearTimeout(_toastTimer); + _toastTimer = setTimeout(() => { + const msg = t(msgKey).replace('{n}', maxLen); + showChanToast(msg, '', 'err'); + }, 300); + } + + // Toast-based: for elements with maxLength attribute (input/textarea) —— 硬上限,不会截断 + function bindToastLimit(el) { + if (!el || !el.maxLength || el.maxLength < 0) return; + el.addEventListener('input', () => { + if (el.value.length >= el.maxLength) limitToast(el.maxLength, 'err.charLimitReached'); + }); + } + + // Toast-based: for contenteditable elements (no native maxLength) + // Note: we only warn on input, not hard-truncate, because truncating innerHTML + // would destroy embedded chips, cursor position, and break IME composition. + // Actual truncation happens at send time in submitInput(). + function bindContentEditableLimit(el, maxLen) { + if (!el) return; + let composing = false; + el.addEventListener('compositionstart', () => { composing = true; }); + el.addEventListener('compositionend', () => { + composing = false; + trimExcess(); + }); + // Layer 1: block non-IME input when at capacity + el.addEventListener('beforeinput', (e) => { + if (composing) return; // let IME through, trim after compositionend + if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') return; + if (e.inputType && e.inputType.startsWith('delete')) return; + if (el.textContent.length >= maxLen) { + e.preventDefault(); + limitToast(maxLen); + } + }); + // Layer 2: after IME commits, trim excess from last text node + function trimExcess() { + const cur = el.textContent.length; + if (cur <= maxLen) return; + const excess = cur - maxLen; + // Walk text nodes in reverse to trim from the end (skip chip internals) + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + const textNodes = []; + while (walker.nextNode()) textNodes.push(walker.currentNode); + if (!textNodes.length) return; + let toRemove = excess; + for (let i = textNodes.length - 1; i >= 0 && toRemove > 0; i--) { + const node = textNodes[i]; + if (node.nodeValue.length <= toRemove) { + toRemove -= node.nodeValue.length; + node.nodeValue = ''; + } else { + node.nodeValue = node.nodeValue.slice(0, node.nodeValue.length - toRemove); + toRemove = 0; + } + } + limitToast(maxLen); + } + } + + // Per-field inline hint: creates a small red span right after the input. + // 用 JS 管控长度(去掉原生 maxlength),以便"超限尝试"时可靠告警: + // 超出上限的字符先被键入,再由本逻辑截回上限并告警——每次超限都触发 input, + // 兼容打字/粘贴/中文 IME。到达上限本身合法、不告警。告警出现后自动消失,不常驻。 + function bindFieldInlineLimit(el) { + if (!el || !el.maxLength || el.maxLength < 0) return; + const max = el.maxLength; + el.removeAttribute('maxlength'); // 转为 JS 管控 + const hint = document.createElement('span'); + hint.className = 'field-limit-hint'; + hint.style.cssText = 'color:var(--err,#dc2626);font-size:.75rem;display:none;margin-top:2px'; + el.insertAdjacentElement('afterend', hint); + let hideTimer = null; + function warn() { + hint.textContent = t('err.charLimitReached').replace('{n}', max); + hint.style.display = 'block'; + clearTimeout(hideTimer); + hideTimer = setTimeout(() => { hint.style.display = 'none'; }, 2500); + } + function enforce() { + if (el.value.length <= max) return; + const atEnd = el.selectionStart >= el.value.length; + el.value = el.value.slice(0, max); + if (atEnd) el.setSelectionRange(max, max); + warn(); + } + let composing = false; + el.addEventListener('compositionstart', () => { composing = true; }); + el.addEventListener('compositionend', () => { composing = false; enforce(); }); + el.addEventListener('input', () => { if (!composing) enforce(); }); + } + + window.bindFieldInlineLimit = bindFieldInlineLimit; + window.bindToastLimit = bindToastLimit; + + // Number field: clamp to max on blur, no red text; block non-integer chars + function bindNumberClamp(el) { + if (!el || !el.max) return; + const max = Number(el.max); + if (!max) return; + // Block all non-digit keys (allow navigation/editing keys) + el.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey || e.altKey) return; // allow shortcuts + if (['Backspace','Delete','Tab','ArrowLeft','ArrowRight','Home','End'].includes(e.key)) return; + if (e.key.length === 1 && !/[0-9]/.test(e.key)) e.preventDefault(); + }); + el.addEventListener('input', () => { + // Strip non-digit chars (IME can bypass keydown) + const cleaned = el.value.replace(/[^0-9]/g, ''); + if (cleaned !== el.value) el.value = cleaned; + const v = Number(el.value); + if (el.value !== '' && v > max) el.value = max; + }); + } + + // Wait for DOM ready + function setup() { + // Contenteditable inputs (chat + collab) + const chatInput = document.querySelector('.input[contenteditable][data-i18n-ph="composer.placeholder"]'); + const collabInput = document.getElementById('cdb-input'); + bindContentEditableLimit(chatInput, 20000); + bindContentEditableLimit(collabInput, 20000); + + // Toast targets (standard inputs/textareas with maxLength) + const searchInput = document.querySelector('.search input'); + const mykeyEditor = document.getElementById('chan-config-editor'); + [searchInput, mykeyEditor].forEach(bindToastLimit); + + // Model form: per-field inline hints + const form = document.getElementById('add-model-form'); + if (form) { + ['model', 'apikey', 'apibase', 'name'].forEach(name => { + bindFieldInlineLimit(form.querySelector(`[name="${name}"]`)); + }); + ['max_retries', 'connect_timeout', 'read_timeout'].forEach(name => { + bindNumberClamp(form.querySelector(`[name="${name}"]`)); + }); + } + + // Preset form: 每字段独立内联提示(标题/Prompt 各自独立,互不串扰) + bindFieldInlineLimit(document.getElementById('cp-title')); + bindFieldInlineLimit(document.getElementById('cp-prompt')); + + // First-run desktop-shortcut prompt (Windows portable bundle only). Driven from the web UI + // so the dialog always renders on top — a native dialog from the Rust startup thread had no + // parent window and got buried behind the main window on first launch. + maybeAskDesktopShortcut(); + } + + async function maybeAskDesktopShortcut() { + try { + const should = await window.ga.tauriInvoke('shortcut_should_ask'); + if (!should) return; + const create = await showConfirmDialog({ title: t('common.confirm'), message: t('shortcut.askConfirm'), okText: t('common.confirm') }); + await window.ga.tauriInvoke('shortcut_decide', { create }); + } catch (_) { /* not in tauri / not a bundle — ignore */ } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setup); + } else { + setup(); + } +})(); + +function channelDisplayName(ch) { + const file = (ch.name || ch.id || '').split('/').pop(); + const key = CHAN_FILE_LABELS[file]; + return key ? t(key) : (ch.name || ch.id || ''); +} +function channelStatusClass(status) { + if (status === 'running') return 'on'; + if (status === 'error') return 'err'; + return 'off'; +} +function channelStatusLabel(status) { + const map = { + running: 'st.running', offline: 'st.offline', error: 'st.error', + starting: 'st.starting', stopping: 'st.stopping', + }; + return t(map[status] || 'st.offline'); +} +function channelErrorMessage(code) { + const map = { not_configured: 'err.channelNotConfigured' }; + return t(map[code] || code || 'err.channelStart'); +} +function channelToastDetail(e) { + const svc = e.data && e.data.service; + if (svc && svc.lastError) return svc.lastError; + const code = e.data && e.data.error; + return channelErrorMessage(code || e.message); +} +function renderChannelList(channels) { + if (!chanListEl) return; + const rows = (channels || []).filter((ch) => (ch.id || '').startsWith('frontends/')); + chanListEl.innerHTML = ''; + if (chanEmptyEl) chanEmptyEl.hidden = rows.length > 0; + for (const ch of rows) { + const row = document.createElement('div'); + row.className = 'list-row'; + row.dataset.channelId = ch.id; + const stClass = channelStatusClass(ch.status || 'offline'); + const running = !!ch.running; + row.innerHTML = ` + ${CHAN_ICON} +
+ + +
+ + + + + `; + row.querySelector('.chan-name').textContent = channelDisplayName(ch); + row.querySelector('.chan-path').textContent = ch.name || ch.id; + row.querySelector('.chan-status').textContent = channelStatusLabel(ch.status || 'offline'); + row.querySelector('[data-act="configure"]').textContent = t('act.configure'); + row.querySelector('[data-act="logs"]').textContent = t('act.logs'); + chanListEl.appendChild(row); + } +} +async function toggleChannel(id, running, toggleEl) { + if (_chanBusy) return; + _chanBusy = true; + if (toggleEl) toggleEl.disabled = true; + const label = channelDisplayName(gaServiceStore.get(id) || { id }); + try { + if (running) { + await window.ga.stopService(id); + showChanToast(t('sys.channelStopped') + ' · ' + label, '', 'ok'); + } else { + const res = await window.ga.startService(id); + if (res && res.service && res.service.status === 'error') { + throw Object.assign(new Error(res.service.lastError || 'start_failed'), { data: res }); + } + showChanToast(t('sys.channelStarted') + ' · ' + label, '', 'ok'); + } + } catch (e) { + showChanToast( + (running ? t('err.channelStop') : t('err.channelStart')) + ' · ' + label, + channelToastDetail(e), + 'err' + ); + } finally { + _chanBusy = false; + if (toggleEl) toggleEl.disabled = false; + } +} +async function openChannelLogs(id) { + if (!chanLogModal || !chanLogPre) return; + _chanLogId = id; + const ch = gaServiceStore.get(id) || { id }; + const titleName = id === '__bridge__' ? (ch.name || 'bridge') : statusDisplayName(ch); + if (chanLogTitle) chanLogTitle.textContent = t('modal.channelLogs') + ' · ' + titleName; + chanLogPre.textContent = t('ch.loading'); + openModal('chan-log-modal'); + try { + const res = await window.ga.getServiceLogs(id, 200); + const lines = res.lines || []; + chanLogPre.textContent = lines.length ? lines.join('\n') : t('ch.logEmpty'); + } catch (e) { + chanLogPre.textContent = t('err.channelLoad') + ': ' + (e.message || e); + } +} +async function openChannelMykey(channelId) { + if (!chanConfigModal || !chanConfigEditor) return; + const ch = gaServiceStore.get(channelId) || { id: channelId }; + if (chanConfigTitle) { + chanConfigTitle.textContent = t('modal.mykeyConfig') + (channelId ? ' · ' + channelDisplayName(ch) : ''); + } + chanConfigEditor.value = t('ch.loading'); + chanConfigEditor.disabled = true; + if (chanConfigSave) chanConfigSave.disabled = true; + openModal('chan-config-modal'); + try { + const res = await window.ga.getMykeyContent(); + chanConfigEditor.value = res.content || ''; + } catch (e) { + chanConfigEditor.value = t('err.channelLoad') + ': ' + (e.message || e); + } finally { + chanConfigEditor.disabled = false; + if (chanConfigSave) chanConfigSave.disabled = false; + chanConfigEditor.focus(); + } +} +async function saveChannelMykey() { + if (!chanConfigEditor || !chanConfigSave) return; + chanConfigSave.disabled = true; + try { + await window.ga.saveMykeyContent(chanConfigEditor.value); + showChanToast(t('sys.configSaved'), '', 'ok'); + chanConfigModal.hidden = true; + } catch (e) { + showChanToast(t('err.channelLoad'), e.message || String(e), 'err'); + } finally { + chanConfigSave.disabled = false; + } +} +if (chanConfigSave) { + chanConfigSave.addEventListener('click', saveChannelMykey); +} + +/* ═══════════════ 状态面板(复用 ServiceManager + 启停/日志) ═══════════════ */ +const statusListEl = document.getElementById('status-list'); +const BRIDGE_SERVICE_ID = '__bridge__'; +const EXTRA_SERVICE_IDS = new Set(['frontends/conductor.py', 'reflect/scheduler.py']); + +function bridgeOfflinePanelServices() { + return [ + { + id: BRIDGE_SERVICE_ID, + name: `bridge (:${BRIDGE_PORT})`, + status: 'offline', + running: false, + pid: null, + memMb: null, + cpuPct: null, + managed: false, + }, + { + id: 'frontends/conductor.py', + name: 'frontends/conductor.py', + status: 'offline', + running: false, + pid: null, + memMb: null, + cpuPct: null, + managed: false, + bridgeOffline: true, + }, + { + id: 'reflect/scheduler.py', + name: 'reflect/scheduler.py', + status: 'offline', + running: false, + pid: null, + memMb: null, + cpuPct: null, + managed: false, + bridgeOffline: true, + }, + ]; +} + +function statusDisplayName(s) { + if (!s) return ''; + if (s.id === BRIDGE_SERVICE_ID) return s.name || 'bridge'; + if (s.id === 'reflect/scheduler.py') return t('proc.scheduler'); + if (s.id === 'frontends/conductor.py') return t('proc.conductor'); + return channelDisplayName(s); +} +function fmtPid(pid) { return pid ? `PID ${pid}` : '—'; } +function fmtRes(s) { + const cpu = s.cpuPct != null ? `${s.cpuPct}%` : '—'; + const mem = s.memMb != null ? `${s.memMb}MB` : '—'; + return `${cpu} / ${mem}`; +} + +function renderStatusPanel(services) { + if (!statusListEl) return; + statusListEl.innerHTML = ''; + for (const s of services || []) { + const row = document.createElement('div'); + row.className = 'list-row'; + row.dataset.serviceId = s.id; + const stClass = channelStatusClass(s.status || 'offline'); + const running = !!s.running; + const managed = s.managed !== false; + const isBridge = s.id === BRIDGE_SERVICE_ID; + const isExtra = EXTRA_SERVICE_IDS.has(s.id); + let acts = ''; + if (isBridge) { + if (running) { + acts += ``; + acts += ``; + } else { + acts += ``; + } + } else if (!s.bridgeOffline) { + acts += ``; + if (managed) { + if (running) acts += ``; + if (isExtra) { + acts += ``; + } else { + acts += ``; + } + } + } + row.innerHTML = ` + + + + + + ${acts}`; + row.querySelector('.st-name').textContent = statusDisplayName(s); + row.querySelector('.st-status').textContent = channelStatusLabel(s.status || 'offline'); + row.querySelector('.st-pid').textContent = fmtPid(s.pid); + row.querySelector('.st-res').textContent = fmtRes(s); + const logBtn = row.querySelector('[data-act="logs"]'); + if (logBtn) logBtn.textContent = t('act.logs'); + const rstBtn = row.querySelector('[data-act="restart"]'); + if (rstBtn) rstBtn.textContent = t('act.restart'); + const startBridgeBtn = row.querySelector('[data-act="bridge-start"]'); + if (startBridgeBtn) startBridgeBtn.textContent = t('act.start'); + const exitBridgeBtn = row.querySelector('[data-act="bridge-exit"]'); + if (exitBridgeBtn) exitBridgeBtn.textContent = t('act.exit'); + const textToggleBtn = row.querySelector('.link-btn[data-act="toggle"]'); + if (textToggleBtn) textToggleBtn.textContent = running ? t('act.exit') : t('act.start'); + statusListEl.appendChild(row); + } +} + +async function loadStatusPanel() { + if (!statusListEl) return; + if (bridgeUiOffline) { + renderStatusPanel(bridgeOfflinePanelServices()); + return; + } + try { + const res = await window.ga.getServicePanel(); + renderStatusPanel(res.services || []); + } catch (_) { + window.ga.setBridgeUiOffline(true); + gaServiceStore.applySnapshot(bridgeOfflinePanelServices()); + renderStatusPanel(bridgeOfflinePanelServices()); + } +} + +async function restartService(id) { + const label = statusDisplayName(gaServiceStore.get(id) || { id }); + await window.ga.stopService(id); + const res = await window.ga.startService(id); + if (res && res.service && res.service.status === 'error') { + throw Object.assign(new Error(res.service.lastError || 'start_failed'), { data: res }); + } + showChanToast(t('act.restart') + ' · ' + label, '', 'ok'); +} + +if (statusListEl) { + statusListEl.addEventListener('click', async (e) => { + const row = e.target.closest('.list-row'); + if (!row) return; + const id = row.dataset.serviceId; + const actEl = e.target.closest('[data-act]'); + if (!actEl || !id) return; + const act = actEl.dataset.act; + if (act === 'logs') { + openChannelLogs(id); + return; + } + if (act === 'bridge-start') { + if (_chanBusy) return; + _chanBusy = true; + actEl.disabled = true; + try { + await window.ga.spawnBridge(); + await loadStatusPanel(); + showChanToast(t('sys.channelStarted') + ' · bridge', '', 'ok'); + } catch (err) { + showChanToast(t('err.channelStart') + ' · bridge', err.message || String(err), 'err'); + } finally { + _chanBusy = false; + actEl.disabled = false; + } + return; + } + if (act === 'bridge-exit') { + if (_chanBusy) return; + _chanBusy = true; + actEl.disabled = true; + try { + window.ga.setBridgeUiOffline(true); + gaServiceStore.applySnapshot(bridgeOfflinePanelServices()); + renderStatusPanel(bridgeOfflinePanelServices()); + await window.ga.exitBridge(); + showChanToast(t('sys.channelStopped') + ' · bridge', '', 'ok'); + } catch (err) { + showChanToast(t('err.channelStop') + ' · bridge', err.message || String(err), 'err'); + } finally { + _chanBusy = false; + actEl.disabled = false; + } + return; + } + if (act === 'restart') { + if (_chanBusy) return; + _chanBusy = true; + try { + await restartService(id); + await loadStatusPanel(); + } catch (err) { + showChanToast(t('act.restart') + ' · ' + statusDisplayName({ id }), err.message || String(err), 'err'); + } finally { + _chanBusy = false; + } + return; + } + if (act === 'toggle') { + if (actEl.disabled || _chanBusy) return; + const running = actEl.classList.contains('on'); + await toggleChannel(id, running, actEl); + if (isSvcTab('status')) loadStatusPanel(); + } + }); +} + +gaServiceStore.onServices((list) => { + if (isSvcTab('channels')) renderChannelList(list); + if (isSvcTab('status')) { + if (bridgeUiOffline) renderStatusPanel(bridgeOfflinePanelServices()); + else loadStatusPanel(); + } +}); +if (chanListEl) { + chanListEl.addEventListener('click', async (e) => { + const row = e.target.closest('.list-row'); + if (!row) return; + const id = row.dataset.channelId; + const actEl = e.target.closest('[data-act]'); + if (!actEl || !id) return; + const act = actEl.dataset.act; + if (act === 'logs') { + openChannelLogs(id); + return; + } + if (act === 'configure') { + openChannelMykey(id); + return; + } + if (act === 'toggle') { + if (actEl.disabled || _chanBusy) return; + const running = actEl.classList.contains('on'); + await toggleChannel(id, running, actEl); + } + }); +} + +/* ═══════════════ 启动 ═══════════════ */ +(async () => { +await loadSessions(); +applyAppearance(appearance, plainUi, { persist: false }); +applyTheme(theme, { persist: false }); +initChatFontStepper(); +applyChatFontSize(chatFontSize, { persist: false }); +syncHljsTheme(); +applyI18n(); +updateModelChip(); +renderSessionList(); +loadCustomPresets(); +loadHiddenBuiltins(); +renderAllPresets(); +if (state.activeId) setActiveSession(state.activeId); +else refreshEmptyState(null); +// bridge-ready 可能在上面的 await 期间就已到达(WS 一连上 bridge 即推送), +// 此时 state.bridgeReady 已为 true,直接按真实状态渲染,避免把「就绪」覆盖回「连接中」。 +if (state.bridgeReady) refreshStatusLabel(); +else chatStatus.setConnecting(); +window.ga.startBridge && window.ga.startBridge(); +})(); + +/* 聊天 / Conductor 共用 composer 绑定(结构:.composer > .composer-slot > .composer-inset) */ +function bindComposerInRoot(root, opts) { + if (!root || root.dataset.composerBound) return null; + root.dataset.composerBound = '1'; + const ctx = opts.ctx || root.dataset.composerCtx || 'chat'; + const input = root.querySelector('.composer-inset .input'); + const fileInput = root.querySelector('input[type="file"]'); + const plusBtn = root.querySelector('.composer-plus'); + const menu = root.querySelector('.composer-menu'); + const sendBtn = root.querySelector('.send'); + + function closeMenu() { + if (!menu) return; + menu.hidden = true; + plusBtn?.setAttribute('aria-expanded', 'false'); + } + + function openMenu() { + if (!menu || !plusBtn) return; + closeAllModelMenus?.(); + if (ctx === 'chat') window.collabComposer?.closeMenu?.(); + else window.chatComposer?.closeMenu?.(); + menu.hidden = false; + plusBtn.setAttribute('aria-expanded', 'true'); + } + + function toggleMenu() { + if (!menu) return; + if (menu.hidden) openMenu(); + else closeMenu(); + } + + function doSend() { opts.onSend?.(); } + + plusBtn?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + toggleMenu(); + }); + + menu?.addEventListener('click', (e) => { + const item = e.target.closest('[data-composer-action]'); + if (!item) return; + e.stopPropagation(); + closeMenu(); + const act = item.dataset.composerAction; + if (act === 'upload') { + window.gaSetActiveFileComposer?.(ctx); + fileInput?.click(); + return; + } + if (act === 'preset') openModal('preset-modal'); + }); + + input?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { + e.preventDefault(); + doSend(); + } + }); + + sendBtn?.addEventListener('click', (e) => { + e.preventDefault(); + doSend(); + }); + + opts.afterBind?.(root, { input, closeMenu, doSend }); + return { ctx, input, closeMenu, focus: () => input?.focus() }; +} + +(function () { + 'use strict'; + const root = document.getElementById('chat-composer'); + const bound = bindComposerInRoot(root, { + ctx: 'chat', + onSend() { + const sess = activeSess(); + if (sess && rt(sess).busy) { cancelPrompt(); return; } + submitInput(); + }, + }); + if (bound) window.chatComposer = { closeMenu: bound.closeMenu, focus: bound.focus }; +})(); + +(function () { + 'use strict'; + const root = document.getElementById('cdb-composer'); + if (!root) return; + + let onSend = null; + const input = root.querySelector('.composer-inset .input'); + const sendBtn = root.querySelector('.send'); + + function text() { return window.gaComposerText?.('collab') ?? ''; } + function clearIfMatch(raw) { + if (input && text().trim() === String(raw || '').trim()) input.innerHTML = ''; + } + function setEnabled(on) { + if (input) input.contentEditable = on ? 'true' : 'false'; + if (sendBtn) sendBtn.disabled = !on; + } + + const bound = bindComposerInRoot(root, { + ctx: 'collab', + onSend() { if (onSend) onSend(text()); }, + afterBind() { + document.querySelectorAll('#collab-quick [data-prompt-key]').forEach((btn) => { + btn.addEventListener('click', () => { + if (!onSend) return; + const key = btn.dataset.promptKey; + onSend((window.gaT && window.gaT(key)) || key); + }); + }); + }, + }); + if (!bound) return; + + function init(handler) { + onSend = handler; + } + + window.collabComposer = { + init, text, clearIfMatch, setEnabled, + focus: bound.focus, + closeMenu: bound.closeMenu, + }; +})(); + +/* Conductor 页 — 直连 Conductor WS,不走 bridge session */ +(function () { + 'use strict'; + const wsUrl = () => `${CONDUCTOR_WS_ORIGIN}/ws`; + const FAIL_MAX = 5, RECON_BASE = 1200, RECON_MAX = 30000; + const $ = id => document.getElementById(id); + const t = k => (window.gaT && window.gaT(k)) || k; + const esc = s => (window.gaEscapeHtml ? window.gaEscapeHtml(s) : String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))); + const stripAttach = text => String(text || '') + .replace(/\[(Image|File)\s+#\d+\]\s*/g, '') + .replace(/[^\s]*desktop_uploads[^\s]*\s*/g, '') // 兜底:去掉内联的本地上传路径,避免历史/回显消息把全路径甩出来 + .trim(); + const GA_STATUS_BREATHE_SM = ''; + function collabStatusMark(status) { + switch (status) { + case 'running': return GA_STATUS_BREATHE_SM; + case 'reported': return ''; + case 'paused': return ''; + case 'failed': return ''; + case 'terminated': return ''; + default: return ''; + } + } + const ST_KEYS = { running: 'collab.stRunning', reported: 'collab.stReported', paused: 'collab.stPaused', failed: 'collab.stFailed', terminated: 'collab.stTerminated' }; + + const S = { + everConnected: false, reconnecting: false, serviceAvailable: false, + messages: [], workers: [], runningCount: 0, + conductorTyping: false, failCount: 0, + historyReady: false, reconnectAt: 0, progressOpen: false, + }; + let ws, connectTimer, reconnectTick, titleSeq = 0, wsGen = 0, localSeq = 0; + const titleSeen = new Map(); + let prevRail = { running: 0, done: 0, issue: 0, count: 0, sig: '' }; + const prevUpdated = new Map(); + const collabStatus = window.gaPageStatusBar?.($('collab-run-toggle')); + + let draftEl = null; + + const scrollMsgs = () => { + const root = $('collab-msgs'); + const sc = root?.querySelector('.collab-scroll'); + if (sc) sc.scrollTop = sc.scrollHeight; + }; + const showDraft = () => S.conductorTyping && S.serviceAvailable && S.historyReady && S.messages.length > 0; + + function workerSig(list) { + return (list || []).map(w => `${w.id}:${w.updatedAt}:${w.status}`).join('|'); + } + + function pulseEl(el) { + if (!el) return; + el.classList.remove('pulse'); + void el.offsetWidth; + el.classList.add('pulse'); + } + + function syncProgressDrawer() { + const page = document.querySelector('.page[data-page="collab"]'); + if (page) page.classList.toggle('collab-prog-open', S.progressOpen); + } + + function syncRail(opts = {}) { + const rail = $('collab-rail'); + const hasChat = S.historyReady && S.messages.length > 0; + if (rail) rail.hidden = !hasChat; + + const running = S.workers.filter(w => w.status === 'running').length; + const done = S.workers.filter(w => w.status === 'reported').length; + const issue = S.workers.filter(w => w.status === 'failed').length; + const runBadge = $('collab-rail-run'); + const doneBadge = $('collab-rail-done'); + const issueBadge = $('collab-rail-issue'); + const runN = $('collab-rail-run-n'); + const doneN = $('collab-rail-done-n'); + const issueN = $('collab-rail-issue-n'); + + if (runBadge) runBadge.hidden = running <= 0; + if (doneBadge) doneBadge.hidden = done <= 0; + if (issueBadge) issueBadge.hidden = issue <= 0; + if (runN) runN.textContent = String(running); + if (doneN) doneN.textContent = String(done); + if (issueN) issueN.textContent = String(issue); + + const sig = workerSig(S.workers); + if (opts.pulse) { + if (running > prevRail.running || S.workers.length > prevRail.count) pulseEl(runBadge); + if (done > prevRail.done) pulseEl(doneBadge); + if (issue > prevRail.issue) pulseEl(issueBadge); + } else if (sig !== prevRail.sig) { + if (running !== prevRail.running) pulseEl(runBadge); + if (done !== prevRail.done) pulseEl(doneBadge); + if (issue !== prevRail.issue) pulseEl(issueBadge); + } + prevRail = { running, done, issue, count: S.workers.length, sig }; + syncProgressDrawer(); + } + + function toggleProgress(open) { + S.progressOpen = typeof open === 'boolean' ? open : !S.progressOpen; + syncRail(); + } + + function clearDraft() { + if (draftEl) { draftEl.remove(); draftEl = null; } + } + + function syncDraft() { + const list = $('collab-msg-list'); + if (!list || list.hidden || !showDraft()) return clearDraft(); + if (!draftEl) draftEl = document.createElement('div'); + draftEl.className = 'msg system collab-msg-enter'; + draftEl.setAttribute('aria-label', t('collab.typing')); + draftEl.innerHTML = '
'; + list.appendChild(draftEl); + requestAnimationFrame(scrollMsgs); + } + + function relTime(ts) { + if (!ts) return ''; + const ms = typeof ts === 'number' ? (ts > 1e12 ? ts : ts * 1000) : Date.parse(ts); + if (!ms || Number.isNaN(ms)) return ''; + const sec = Math.max(0, Math.floor((Date.now() - ms) / 1000)); + if (sec < 10) return t('collab.timeJust'); + if (sec < 60) return t('collab.timeSec').replace('{n}', sec); + const min = Math.floor(sec / 60); + if (min < 60) return t('collab.timeMin').replace('{n}', min); + const hr = Math.floor(min / 60); + return hr < 24 ? t('collab.timeHr').replace('{n}', hr) : t('collab.timeDay').replace('{n}', Math.floor(hr / 24)); + } + + function mapStatus(status, reply) { + const r = (reply || '').trim(); + if (status === 'running') return 'running'; + if (status === 'failed') return 'failed'; + if (status === 'aborted') return 'terminated'; + if (status === 'stopped') return r ? 'reported' : 'paused'; + return 'paused'; + } + + function normalizeWorker(raw) { + if (!titleSeen.has(raw.id)) titleSeen.set(raw.id, ++titleSeq); + const ui = mapStatus(raw.status, raw.reply); + let title = String(raw.prompt ?? '').replace(/^[\s请帮我麻烦]+/u, '').trim(); + if (!title) title = t('collab.taskFallback').replace('{n}', titleSeen.get(raw.id)); + else { + title = (title.split(/[\n。!?.!?]/)[0] || '').trim(); + if (title.length > 18) title = title.slice(0, 18) + '…'; + } + const reply = String(raw.reply || '').replace(/\s+/g, ' ').trim(); + let summary = reply ? (reply.length > 80 ? reply.slice(0, 80) + '…' : reply) : t(ui === 'running' ? 'collab.summaryRunning' : 'collab.summaryWait'); + return { id: raw.id, title, status: ui, summary, fullReply: raw.reply || '', updatedAt: raw.updated_at }; + } + + function syncCollabStatus() { + if (!collabStatus) return; + if (S.conductorTyping && S.serviceAvailable) collabStatus.setBusy(t('status.running')); + else if (S.serviceAvailable) collabStatus.setReady(); + else if (S.reconnecting || (!S.everConnected && S.failCount < FAIL_MAX)) collabStatus.setConnecting(); + else collabStatus.set(t('collab.offlineShort'), 'offline'); // 顶栏直接显示"无法连接 Conductor(8900)"取代"未连接" + } + + function setConnUi() { + const retry = $('collab-retry'); + const avail = S.serviceAvailable; + const trying = !avail && !S.everConnected && S.failCount < FAIL_MAX; + // 只有"真离线且不在自动重连"才显示刷新图标(右边的手动重试入口) + if (retry) retry.hidden = avail || S.reconnecting || trying; + window.collabComposer?.setEnabled?.(avail); + syncCollabStatus(); + syncDraft(); + syncRail(); + } + + let cardMenu = null; + function hideCardMenu() { if (cardMenu) { cardMenu.remove(); cardMenu = null; } } + function showCardMenu(x, y, sid) { + hideCardMenu(); + cardMenu = document.createElement('div'); + cardMenu.className = 'ctx-menu'; + cardMenu.style.left = x + 'px'; + cardMenu.style.top = y + 'px'; + cardMenu.innerHTML = `
${GA_ICON('trash')}${esc(t('ctx.del'))}
`; + cardMenu.querySelector('.ctx-item').onclick = (e) => { + e.stopPropagation(); + fetch(`${CONDUCTOR_ORIGIN}/subagent/${sid}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'kill' }) }); + hideCardMenu(); + }; + document.body.appendChild(cardMenu); + setTimeout(() => document.addEventListener('mousedown', (e) => { if (!cardMenu?.contains(e.target)) hideCardMenu(); }, { once: true }), 0); + } + + let drawerEl = null; + function closeWorkerDrawer() { if (drawerEl) { drawerEl.remove(); drawerEl = null; } } + function openWorkerDrawer(w) { + closeWorkerDrawer(); + drawerEl = document.createElement('div'); + drawerEl.className = 'collab-drawer-wrap'; + drawerEl.innerHTML = `
`; + const bubble = drawerEl.querySelector('.collab-drawer-body .bubble'); + if (bubble) { + bubble.innerHTML = renderTurnBody(w.fullReply || t('collab.summaryWait')); + postRenderEnhance(bubble); + } + drawerEl.querySelector('.collab-drawer-backdrop').onclick = closeWorkerDrawer; + drawerEl.querySelector('.collab-drawer-close').onclick = closeWorkerDrawer; + document.body.appendChild(drawerEl); + } + + function renderWorkers() { + const box = $('collab-workers'), empty = $('collab-progress-empty'); + if (!box) return; + if (empty) empty.hidden = S.workers.length > 0; + box.innerHTML = S.workers.map(w => ` +
+
${collabStatusMark(w.status)}${esc(t(ST_KEYS[w.status] || 'collab.stPaused'))}${w.updatedAt ? `${esc(relTime(w.updatedAt))}` : ''}
+
${esc(w.title)}
+
${esc(w.summary)}
+
`).join(''); + box.querySelectorAll('.collab-card').forEach(el => { + const w = S.workers.find(x => x.id === el.dataset.sid); + if (w) { + const prev = prevUpdated.get(w.id); + if (prev != null && prev !== w.updatedAt) pulseEl(el); + prevUpdated.set(w.id, w.updatedAt); + } + el.addEventListener('contextmenu', e => { + e.preventDefault(); + showCardMenu(e.clientX, e.clientY, el.dataset.sid); + }); + el.addEventListener('click', () => { + if (w) openWorkerDrawer(w); + }); + }); + const running = S.workers.filter(w => w.status === 'running').length; + const done = S.workers.filter(w => w.status === 'reported').length; + S.runningCount = running; + document.dispatchEvent(new CustomEvent('collab:running-count', { detail: { count: running } })); + const stats = $('collab-progress-stats'); + if (stats) { + const has = running > 0 || done > 0; + stats.hidden = !has; + if (has) { + stats.innerHTML = [ + running > 0 ? `${GA_STATUS_BREATHE_SM}${running} ${esc(t('collab.statRunning'))}` : '', + done > 0 ? `${done} ${esc(t('collab.statDone'))}` : '', + ].filter(Boolean).join(''); + } + } + syncRail({ pulse: true }); + } + + function syncMessages() { + const area = $('collab-msgs'), welcome = $('collab-welcome'), list = $('collab-msg-list'); + if (!area || !list) return; + if (!S.historyReady) { + area.classList.remove('has-msgs'); + if (welcome) welcome.hidden = true; + list.hidden = true; + syncRail(); + return; + } + const has = S.messages.length > 0; + area.classList.toggle('has-msgs', has); + if (welcome) welcome.hidden = has; + list.hidden = !has; + list.replaceChildren(); + const toMsg = window.gaCollabItemToMsg; + const render = window.gaMsgNode; + if (toMsg && render) { + for (const item of S.messages) { + const el = render(toMsg(item)); + el.classList.add('collab-msg-enter'); + list.appendChild(el); + } + } + syncDraft(); + scrollMsgs(); + syncRail(); + } + + function pushMsg(item) { + if (item.id && S.messages.some(m => m.id === item.id)) return; + if (item.role === 'user') { + const plain = stripAttach(item.msg); + const expand = window.gaExpandFilePlaceholders; + for (let i = S.messages.length - 1; i >= 0; i--) { + const m = S.messages[i]; + // 服务端回显的 msg 是 expand(本地文本)(附件被展开成了本地路径),和本地乐观消息其实是同一条。 + // 命中后保留本地那条的干净显示(占位符 msg + 结构化 files/images 卡片),只补服务端 id/ts, + // 丢弃带路径的回显文本 —— 既去重、又不丢卡片、不外露本地路径,与 chat 显示一致。 + if (m._local && m.role === 'user' && + (stripAttach(m.msg) === plain || m.msg === item.msg || (expand && expand(m.msg) === item.msg))) { + m.id = item.id || m.id; + if (item.ts != null) m.ts = item.ts; + if (item.read != null) m.read = item.read; + m._local = false; + syncMessages(); + setConnUi(); + return; + } + } + } + S.messages.push(item); + if (item.role === 'conductor') S.conductorTyping = false; + syncMessages(); + setConnUi(); + } + + function setWorkers(rawList) { + S.workers = (rawList || []).map(normalizeWorker); + renderWorkers(); + } + + function onWsData(data, gen) { + if (gen !== wsGen) return; + if (data.type === 'hello') { + S.historyReady = true; + S.messages = (data.chat || []).map(raw => ({ id: raw.id, role: raw.role || 'system', msg: raw.msg || '', ts: raw.ts, read: raw.read, files: raw.files || [], images: raw.images || [] })); + S.conductorTyping = !!data.running; + setWorkers(data.subagents || []); + syncMessages(); + setConnUi(); + } else if (data.type === 'subagents') setWorkers(data.items || []); + else if (data.type === 'chat') pushMsg({ id: data.item.id, role: data.item.role || 'system', msg: data.item.msg || '', ts: data.item.ts, read: data.item.read, files: data.item.files || [], images: data.item.images || [] }); + } + + function resetWs() { + wsGen++; + if (!ws) return; + const old = ws; + ws = null; + old.onopen = old.onclose = old.onerror = old.onmessage = null; + try { old.close(); } catch {} + } + + function scheduleReconnect() { + clearTimeout(connectTimer); + clearInterval(reconnectTick); + if (!S.everConnected && S.failCount >= FAIL_MAX) { + S.reconnecting = false; + return setConnUi(); + } + const delay = Math.min(RECON_MAX, RECON_BASE * Math.pow(2, Math.max(0, S.failCount - 1))); + S.reconnectAt = Date.now() + delay; + S.reconnecting = S.everConnected; + setConnUi(); + reconnectTick = setInterval(() => { if (!S.reconnecting) clearInterval(reconnectTick); else setConnUi(); }, 500); + connectTimer = setTimeout(connect, delay); + } + + function connect() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + clearTimeout(connectTimer); + clearInterval(reconnectTick); + const gen = ++wsGen; + setConnUi(); + let sock; + try { sock = new WebSocket(wsUrl()); } catch (e) { + if (gen !== wsGen) return; + S.failCount++; + return scheduleReconnect(); + } + ws = sock; + sock.onopen = () => { + if (gen !== wsGen) return; + S.everConnected = true; + S.serviceAvailable = true; + S.reconnecting = false; + S.failCount = 0; + setConnUi(); + }; + sock.onclose = (ev) => { + if (gen !== wsGen) return; + S.serviceAvailable = false; + if (S.everConnected) S.reconnecting = true; + else S.failCount++; + setConnUi(); + scheduleReconnect(); + }; + sock.onerror = () => {}; + sock.onmessage = ev => { + if (gen !== wsGen) return; + try { onWsData(JSON.parse(ev.data), gen); } catch {} + }; + } + + function sendText(rawText) { + const text = (rawText || '').trim(); + if (!text || !ws || ws.readyState !== WebSocket.OPEN) return false; + window.gaSetActiveFileComposer?.('collab'); + const expand = window.gaExpandFilePlaceholders || (s => s); + const collect = window.gaCollectUsedFiles || (() => []); + const clearUsed = window.gaClearUsedPendingFiles || (() => {}); + const used = collect(text); + const images = [], files = []; + for (const f of used) (f.isImage ? images : files).push(f.isImage ? { path: f.path, dataUrl: f.dataUrl, name: f.name, sid: f.sid } : { path: f.path, name: f.name, sid: f.sid }); + S.messages.push({ id: `_local_${++localSeq}`, _local: true, role: 'user', msg: text, ts: Date.now() / 1000, images, files }); + S.conductorTyping = true; + syncMessages(); + ws.send(JSON.stringify({ msg: expand(text), files, images })); + clearUsed(text); + window.collabComposer?.clearIfMatch?.(text); + setConnUi(); + return true; + } + + $('collab-retry')?.addEventListener('click', () => { S.failCount = 0; S.reconnecting = false; resetWs(); connect(); }); + $('collab-rail-toggle')?.addEventListener('click', () => toggleProgress()); + $('collab-prog-close')?.addEventListener('click', () => toggleProgress(false)); + + window.collabComposer?.init?.(sendText); + + window.collabInit = () => { + window.gaSetActiveFileComposer?.('collab'); + syncMessages(); + setConnUi(); + renderWorkers(); + connect(); + }; + window.collabFocus = () => window.collabComposer?.focus?.(); + window.collabRetranslate = () => { renderWorkers(); syncMessages(); setConnUi(); }; +})(); + +/* ═══════════════ Composer layout: inline / stacked ═══════════════ */ +(function initComposerLayout() { + const BREAKPOINT = 480; + const SINGLE_LINE = 36; + + document.querySelectorAll('.composer-inset').forEach(inset => { + const input = inset.querySelector('.input'); + if (!input) return; + + function update() { + const wide = inset.offsetWidth >= BREAKPOINT; + const single = input.scrollHeight <= SINGLE_LINE; + inset.classList.toggle('is-inline', wide && single); + } + + new ResizeObserver(update).observe(inset); + input.addEventListener('input', update); + update(); + }); +})(); diff --git a/frontends/desktop/static/assets/fonts/README.md b/frontends/desktop/static/assets/fonts/README.md new file mode 100644 index 0000000..f8e5a57 --- /dev/null +++ b/frontends/desktop/static/assets/fonts/README.md @@ -0,0 +1,34 @@ +# Desktop Font Assets + +These font files are the offline Latin bundle for the vanilla desktop shell. + +## Files + +- `azonix-wordmark.woff2` + - Role: brand wordmark only + - Source: converted on 2026-05-25 from the local owner-installed file at `~/Library/Fonts/azonix/Azonix.otf` + - License: owner-provided / verify before external redistribution + - Note: this repo currently treats Azonix as a project-local brand asset + +- `lexend-latin.woff2` + - Role: English titles and navigation + - Source: Google Fonts CSS2 API, specimen page + - Downloaded: 2026-05-25 + - License: SIL Open Font License 1.1 + +- `noto-sans-latin.woff2` + - Role: English body copy and default Latin UI text + - Source: Google Fonts CSS2 API, specimen page + - Downloaded: 2026-05-25 + - License: SIL Open Font License 1.1 + +- `jetbrains-mono-latin.woff2` + - Role: config/value dense controls and numeric surfaces + - Source: Google Fonts CSS2 API, specimen page + - Downloaded: 2026-05-25 + - License: SIL Open Font License 1.1 + +## Notes + +- Only Latin subsets are bundled here. Chinese UI text continues to use the existing system fallback stack. +- Runtime does not fetch fonts from a CDN. `frontends/desktop/static/assets/fonts/fonts.css` is the only font entrypoint for the vanilla shell. diff --git a/frontends/desktop/static/assets/fonts/azonix-wordmark.woff2 b/frontends/desktop/static/assets/fonts/azonix-wordmark.woff2 new file mode 100644 index 0000000..947bfbe Binary files /dev/null and b/frontends/desktop/static/assets/fonts/azonix-wordmark.woff2 differ diff --git a/frontends/desktop/static/assets/fonts/fonts.css b/frontends/desktop/static/assets/fonts/fonts.css new file mode 100644 index 0000000..8103b35 --- /dev/null +++ b/frontends/desktop/static/assets/fonts/fonts.css @@ -0,0 +1,21 @@ +@font-face { + font-family: "GA Azonix"; + src: + url("./azonix-wordmark.woff2") format("woff2"), + local("Azonix"), + local("Azonix Regular"), + local("Azonix-Regular"); + font-style: normal; + font-weight: 400; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "GA JetBrains Mono"; + src: url("./jetbrains-mono-latin.woff2") format("woff2"); + font-style: normal; + font-weight: 400 600; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/frontends/desktop/static/assets/fonts/jetbrains-mono-latin.woff2 b/frontends/desktop/static/assets/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 0000000..2ca6ac6 Binary files /dev/null and b/frontends/desktop/static/assets/fonts/jetbrains-mono-latin.woff2 differ diff --git a/frontends/desktop/static/assets/fonts/lexend-latin.woff2 b/frontends/desktop/static/assets/fonts/lexend-latin.woff2 new file mode 100644 index 0000000..0968c15 Binary files /dev/null and b/frontends/desktop/static/assets/fonts/lexend-latin.woff2 differ diff --git a/frontends/desktop/static/assets/fonts/noto-sans-latin.woff2 b/frontends/desktop/static/assets/fonts/noto-sans-latin.woff2 new file mode 100644 index 0000000..e082930 Binary files /dev/null and b/frontends/desktop/static/assets/fonts/noto-sans-latin.woff2 differ diff --git a/frontends/desktop/static/fallback.html b/frontends/desktop/static/fallback.html new file mode 100644 index 0000000..c53f3c4 --- /dev/null +++ b/frontends/desktop/static/fallback.html @@ -0,0 +1,132 @@ + + + + +GenericAgent — Setup + + + +
+

⚙️ Setup Required

+

The backend could not start. Please configure the paths below.

+

后端启动失败,请配置以下路径。

+ + +
+ +
+

+ e.g. C:/Python312/python.exe / ~/miniconda3/envs/myenv/bin/python +

+ + +
+ +
+

+ The folder containing frontends/desktop_bridge.py +

+ + +
+
+ + + + + diff --git a/frontends/desktop/static/ga-web.js b/frontends/desktop/static/ga-web.js new file mode 100644 index 0000000..960f0b9 --- /dev/null +++ b/frontends/desktop/static/ga-web.js @@ -0,0 +1,142 @@ +// GenericAgent Web2 browser bridge adapter. +// HTTP is the command/data channel. WebSocket only carries small state events. +(() => { + 'use strict'; + + const listeners = new Map(); + let ws = null; + let cachedBridgeReady = null; + const bridgeBase = `${location.protocol}//${location.hostname}:14168`; + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.hostname}:14168/ws`; + + function on(channel, cb) { + if (typeof cb !== 'function') return () => {}; + if (!listeners.has(channel)) listeners.set(channel, new Set()); + listeners.get(channel).add(cb); + if (channel === 'bridge-ready' && cachedBridgeReady) { + try { cb(cachedBridgeReady); } catch (err) { console.error('[ga-web2 listener] replay bridge-ready', err); } + } + return () => listeners.get(channel)?.delete(cb); + } + + function emit(channel, payload) { + if (channel === 'bridge-ready') cachedBridgeReady = payload; + const set = listeners.get(channel); + if (!set) return; + for (const cb of Array.from(set)) { + try { cb(payload); } catch (err) { console.error('[ga-web2 listener]', channel, err); } + } + } + + async function http(path, options = {}) { + const headers = Object.assign({}, options.headers || {}); + const init = Object.assign({}, options, { headers }); + if (init.body && typeof init.body !== 'string') { + headers['Content-Type'] = headers['Content-Type'] || 'application/json'; + init.body = JSON.stringify(init.body); + } + const res = await fetch(`${bridgeBase}${path}`, init); + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; } + if (!res.ok) { + const err = new Error((data && (data.error || data.message)) || `${res.status} ${res.statusText}`); + err.status = res.status; + err.data = data; + throw err; + } + return data; + } + + function connectWs() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + try { + ws = new WebSocket(wsUrl); + ws.addEventListener('open', () => emit('bridge-log', 'WS state channel connected')); + ws.addEventListener('message', (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch (_) { return; } + if (msg.type === 'bridge-ready') { + emit('bridge-ready', msg); + } else if (msg.type === 'session-state') { + emit('bridge-notification', msg); + } else if (msg.type === 'bridge-log') { + emit('bridge-log', msg.payload || msg); + } else if (msg.type === 'bridge-error') { + emit('bridge-error', msg.payload || msg); + } + }); + ws.addEventListener('close', () => emit('bridge-closed', { reason: 'ws-closed' })); + ws.addEventListener('error', () => emit('bridge-error', { type: 'ws-error', message: 'WebSocket state channel error' })); + } catch (err) { + emit('bridge-error', { type: 'ws-error', message: err.message || String(err) }); + } + } + + async function rpc(method, params = {}) { + switch (method) { + case 'app/status': + return http('/status'); + case 'app/config/get': + return http('/config'); + case 'app/config/save': + return http('/config', { method: 'POST', body: params || {} }); + case 'get/model-profiles': + return http('/model-profiles'); + case 'session/new': + return http('/session/new', { method: 'POST', body: params || {} }); + case 'session/prompt': { + const sid = params.sessionId || params.id || params.bridgeSessionId; + if (!sid) throw new Error('session/prompt missing sessionId'); + return http(`/session/${encodeURIComponent(sid)}/prompt`, { method: 'POST', body: params || {} }); + } + case 'session/poll': { + const sid = params.sessionId || params.id || params.bridgeSessionId; + if (!sid) throw new Error('session/poll missing sessionId'); + const after = params.afterId ?? params.after ?? 0; + const limit = params.limit ?? 200; + return http(`/session/${encodeURIComponent(sid)}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`); + } + case 'session/cancel': { + const sid = params.sessionId || params.id || params.bridgeSessionId; + if (!sid) throw new Error('session/cancel missing sessionId'); + return http(`/session/${encodeURIComponent(sid)}/cancel`, { method: 'POST', body: params || {} }); + } + case 'app/path/open': + return http('/path/open', { method: 'POST', body: params || {} }); + case 'app/path/selectGaRoot': + return http('/config'); + case 'list_continuable_sessions': + return { sessions: [] }; + case 'restore_session': + throw new Error('restore_session is not implemented in web2 bridge'); + default: + throw new Error(`Unknown RPC method: ${method}`); + } + } + + window.ga = { + platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32', + startBridge: async () => { connectWs(); return http('/status'); }, + stopBridge: async () => ({ ok: true }), + checkStatus: () => rpc('app/status', {}), + getConfig: () => rpc('app/config/get', {}), + saveConfig: (cfg) => rpc('app/config/save', cfg || {}), + getModelProfiles: () => rpc('get/model-profiles', {}), + selectGaRoot: () => rpc('app/path/selectGaRoot', {}), + openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }), + openMykey: () => rpc('app/path/open', { kind: 'mykey' }), + pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }), + rpc, + onBridgeMessage: (cb) => on('bridge-message', cb), + onBridgeNotification: (cb) => on('bridge-notification', cb), + onBridgeError: (cb) => on('bridge-error', cb), + onBridgeClosed: (cb) => on('bridge-closed', cb), + onBridgeReady: (cb) => on('bridge-ready', cb), + onBridgeLog: (cb) => on('bridge-log', cb), + onOpenSearch: (cb) => on('open-search', cb) + }; + + connectWs(); + http('/status').then(status => emit('bridge-ready', status)).catch(err => emit('bridge-error', { type: 'http-error', message: err.message || String(err) })); +})(); diff --git a/frontends/desktop/static/index.html b/frontends/desktop/static/index.html new file mode 100644 index 0000000..3390ca8 --- /dev/null +++ b/frontends/desktop/static/index.html @@ -0,0 +1,679 @@ + + + + +GenericAgent + + + + + + + + + + + + + + +
+ +
+ + + +
+ + +
+ +
+ + +
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+ +
+ +
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+
+ + +
+
+ + + +
+
+
+
+ + +
+
+
+ +
+
+
+
+
+
+ + +
+
+ +
+ + +
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+ + + +
+
+
+
+
+ +
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+
+ + +
+
+ + + +
+
+
+
+ + +
+
+ + + + + +
+
+
0
+
0%
+
0
+
+ + + + + + + + + + +
+
+
+
+ +
+
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + diff --git a/frontends/desktop/static/loading.html b/frontends/desktop/static/loading.html new file mode 100644 index 0000000..f02eddb --- /dev/null +++ b/frontends/desktop/static/loading.html @@ -0,0 +1,56 @@ + + + + +GenericAgent + + + + +
+
+
正在启动 GenericAgent…
+
+
+ + diff --git a/frontends/desktop/static/phosphor-icons.js b/frontends/desktop/static/phosphor-icons.js new file mode 100644 index 0000000..6a76f64 --- /dev/null +++ b/frontends/desktop/static/phosphor-icons.js @@ -0,0 +1,101 @@ +(() => { + const PATHS = { + chatTeardropText: + 'M172,112a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h68A8,8,0,0,1,172,112Zm-8,24H96a8,8,0,0,0,0,16h68a8,8,0,0,0,0-16Zm68-12A100.11,100.11,0,0,1,132,224H48a16,16,0,0,1-16-16V124a100,100,0,0,1,200,0Zm-16,0a84,84,0,0,0-168,0v84h84A84.09,84.09,0,0,0,216,124Z', + broadcast: + 'M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z', + chartBar: + 'M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z', + usersThree: + 'M244.8,150.4a8,8,0,0,1-11.2-1.6A51.6,51.6,0,0,0,192,128a8,8,0,0,1-7.37-4.89,8,8,0,0,1,0-6.22A8,8,0,0,1,192,112a24,24,0,1,0-23.24-30,8,8,0,1,1-15.5-4A40,40,0,1,1,219,117.51a67.94,67.94,0,0,1,27.43,21.68A8,8,0,0,1,244.8,150.4ZM190.92,212a8,8,0,1,1-13.84,8,57,57,0,0,0-98.16,0,8,8,0,1,1-13.84-8,72.06,72.06,0,0,1,33.74-29.92,48,48,0,1,1,58.36,0A72.06,72.06,0,0,1,190.92,212ZM128,176a32,32,0,1,0-32-32A32,32,0,0,0,128,176ZM72,120a8,8,0,0,0-8-8A24,24,0,1,1,87.24,82a8,8,0,1,0,15.5-4A40,40,0,1,0,37,117.51,67.94,67.94,0,0,0,9.6,139.19a8,8,0,1,0,12.8,9.61A51.6,51.6,0,0,1,64,128,8,8,0,0,0,72,120Z', + coins: + 'M184,89.57V84c0-25.08-37.83-44-88-44S8,58.92,8,84v40c0,20.89,26.25,37.49,64,42.46V172c0,25.08,37.83,44,88,44s88-18.92,88-44V132C248,111.3,222.58,94.68,184,89.57ZM232,132c0,13.22-30.79,28-72,28-3.73,0-7.43-.13-11.08-.37C170.49,151.77,184,139,184,124V105.74C213.87,110.19,232,122.27,232,132ZM72,150.25V126.46A183.74,183.74,0,0,0,96,128a183.74,183.74,0,0,0,24-1.54v23.79A163,163,0,0,1,96,152,163,163,0,0,1,72,150.25Zm96-40.32V124c0,8.39-12.41,17.4-32,22.87V123.5C148.91,120.37,159.84,115.71,168,109.93ZM96,56c41.21,0,72,14.78,72,28s-30.79,28-72,28S24,97.22,24,84,54.79,56,96,56ZM24,124V109.93c8.16,5.78,19.09,10.44,32,13.57v23.37C36.41,141.4,24,132.39,24,124Zm64,48v-4.17c2.63.1,5.29.17,8,.17,3.88,0,7.67-.13,11.39-.35A121.92,121.92,0,0,0,120,171.41v23.46C100.41,189.4,88,180.39,88,172Zm48,26.25V174.4a179.48,179.48,0,0,0,24,1.6,183.74,183.74,0,0,0,24-1.54v23.79a165.45,165.45,0,0,1-48,0Zm64-3.38V171.5c12.91-3.13,23.84-7.79,32-13.57V172C232,180.39,219.59,189.4,200,194.87Z', + sidebarSimple: + 'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM40,56H80V200H40ZM216,200H96V56H216V200Z', + pencilSimple: + 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z', + magnifyingGlass: + 'M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z', + books: + 'M231.65,194.55,198.46,36.75a16,16,0,0,0-19-12.39L132.65,34.42a16.08,16.08,0,0,0-12.3,19l33.19,157.8A16,16,0,0,0,169.16,224a16.25,16.25,0,0,0,3.38-.36l46.81-10.06A16.09,16.09,0,0,0,231.65,194.55ZM136,50.15c0-.06,0-.09,0-.09l46.8-10,3.33,15.87L139.33,66Zm6.62,31.47,46.82-10.05,3.34,15.9L146,97.53Zm6.64,31.57,46.82-10.06,13.3,63.24-46.82,10.06ZM216,197.94l-46.8,10-3.33-15.87L212.67,182,216,197.85C216,197.91,216,197.94,216,197.94ZM104,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32ZM56,48h48V64H56Zm0,32h48v96H56Zm48,128H56V192h48v16Z', + gridFour: + 'M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,80H136V56h64ZM120,56v64H56V56ZM56,136h64v64H56Zm144,64H136V136h64v64Z', + robot: + 'M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16Zm-52-56H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Z', + dotsThree: + 'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,196,116ZM60,116a12,12,0,1,0,12,12A12,12,0,0,0,60,116Z', + folderSimplePlus: + 'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Zm-56-56a8,8,0,0,1-8,8H136v16a8,8,0,0,1-16,0V152H104a8,8,0,0,1,0-16h16V120a8,8,0,0,1,16,0v16h16A8,8,0,0,1,160,144Z', + folderSimple: + 'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Z', + bracketsCurly: + 'M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z', + gear: + 'M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z', + caretLeft: + 'M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z', + caretDown: + 'M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z', + caretRight: + 'M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z', + paperPlaneTilt: + 'M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z', + paperclip: + 'M209.66,122.34a8,8,0,0,1,0,11.32l-82.05,82a56,56,0,0,1-79.2-79.21L147.67,35.73a40,40,0,1,1,56.61,56.55L105,193A24,24,0,1,1,71,159L154.3,74.38A8,8,0,1,1,165.7,85.6L82.39,170.31a8,8,0,1,0,11.27,11.36L192.93,81A24,24,0,1,0,159,47L59.76,147.68a40,40,0,1,0,56.53,56.62l82.06-82A8,8,0,0,1,209.66,122.34Z', + lightning: + 'M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z', + pushPinSimple: + 'M216,168h-9.29L185.54,48H192a8,8,0,0,0,0-16H64a8,8,0,0,0,0,16h6.46L49.29,168H40a8,8,0,0,0,0,16h80v56a8,8,0,0,0,16,0V184h80a8,8,0,0,0,0-16ZM86.71,48h82.58l21.17,120H65.54Z', + trash: + 'M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z', + x: + 'M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z', + dotsThreeVertical: + 'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm0-56a12,12,0,1,1-12-12A12,12,0,0,1,140,72Zm0,112a12,12,0,1,1-12-12A12,12,0,0,1,140,184Z', + plus: + 'M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z', + copy: + 'M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z', + check: + 'M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z', + crosshair: + 'M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z', + compass: + 'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216ZM172.42,72.84l-64,32a8.05,8.05,0,0,0-3.58,3.58l-32,64A8,8,0,0,0,80,184a8.1,8.1,0,0,0,3.58-.84l64-32a8.05,8.05,0,0,0,3.58-3.58l32-64a8,8,0,0,0-10.74-10.74ZM138,138,97.89,158.11,118,118l40.15-20.07Z', + listChecks: + 'M224,128a8,8,0,0,1-8,8H128a8,8,0,0,1,0-16h88A8,8,0,0,1,224,128ZM128,72h88a8,8,0,0,0,0-16H128a8,8,0,0,0,0,16Zm88,112H128a8,8,0,0,0,0,16h88a8,8,0,0,0,0-16ZM82.34,42.34,56,68.69,45.66,58.34A8,8,0,0,0,34.34,69.66l16,16a8,8,0,0,0,11.32,0l32-32A8,8,0,0,0,82.34,42.34Zm0,64L56,132.69,45.66,122.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm0,64L56,196.69,45.66,186.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Z', + star: + 'M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z', + fileText: + 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-32-80a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,136Zm0,32a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,168Z', + gitFork: + 'M224,64a32,32,0,1,0-40,31v17a8,8,0,0,1-8,8H80a8,8,0,0,1-8-8V95a32,32,0,1,0-16,0v17a24,24,0,0,0,24,24h40v25a32,32,0,1,0,16,0V136h40a24,24,0,0,0,24-24V95A32.06,32.06,0,0,0,224,64ZM48,64A16,16,0,1,1,64,80,16,16,0,0,1,48,64Zm96,128a16,16,0,1,1-16-16A16,16,0,0,1,144,192ZM192,80a16,16,0,1,1,16-16A16,16,0,0,1,192,80Z', + hexagon: + 'M223.68,66.15,135.68,18h0a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM216,175.82,128,224,40,175.82V80.18L128,32h0l88,48.17Z', + }; + + function gaIcon(name, className = '') { + const d = PATHS[name]; + if (!d) return ''; + const cls = className ? ` class="${className}"` : ''; + return `
") + else: + html.append("
")
+            in_code = not in_code
+            continue
+        if in_code:
+            html.append(raw.replace("&", "&").replace("<", "<").replace(">", ">"))
+            continue
+        line = raw
+        line = re.sub(r"`([^`]+)`", r"\1", line)
+        line = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
+        line = re.sub(r"\*(.+?)\*", r"\1", line)
+        line = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', line)
+        if re.match(r"^#{1,6}\s", line):
+            lvl = len(line.split()[0])
+            line = f"{line[lvl:].strip()}"
+        elif re.match(r"^-{3,}$|^_{3,}$|^\*{3,}$", line.strip()):
+            line = "
" + elif re.match(r"^\s*[-*+]\s", line): + content = re.sub(r"^\s*[-*+]\s", "", line) + if not in_ul: + html.append("
    ") + in_ul = True + line = f"
  • {content}
  • " + else: + if in_ul: + html.append("
") + in_ul = False + line = f"

{line}

" if line.strip() else "" + html.append(line) + if in_code: + html.append("
") + if in_ul: + html.append("") + return "\n".join(html) + + +_icon_cache: dict[str, QIcon] = {} + +def _svg_icon(key: str, svg_template: str, color: str = "#a1a1aa", + size: int = 16) -> QIcon: + cache_key = f"{key}_{color}_{size}" + if cache_key not in _icon_cache: + try: + from PySide6.QtSvg import QSvgRenderer + except ImportError: + return QIcon() + data = QByteArray(svg_template.format(c=color).encode("utf-8")) + renderer = QSvgRenderer(data) + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + renderer.render(painter) + painter.end() + _icon_cache[cache_key] = QIcon(pixmap) + return _icon_cache[cache_key] + + +# ── utilities ───────────────────────────────────────────────────────────────── +def _make_session_id() -> str: + return datetime.now().strftime("%Y%m%d_%H%M%S_%f") + + +def _load_history() -> list: + if os.path.exists(HISTORY_FILE): + try: + with open(HISTORY_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return [] + + +def _save_history(history: list): + os.makedirs(os.path.dirname(HISTORY_FILE), exist_ok=True) + with open(HISTORY_FILE, "w", encoding="utf-8") as f: + json.dump(history, f, ensure_ascii=False, indent=2) + + +def _build_prompt_with_uploads(prompt: str, files: list) -> tuple: + """ + files: list of {'name': str, 'type': str, 'raw': bytes} + returns (full_prompt, display_prompt, display_attachments) + """ + if not files: + return prompt, prompt, [] + + os.makedirs("temp/uploaded", exist_ok=True) + attachment_chunks = ["\n\n[用户上传附件 — 文件已保存到本地磁盘,可用 file_read 工具读取]"] + display_attachments = [] + img_count, file_names = 0, [] + + for f in files: + raw, name, mime = f["raw"], f["name"], f.get("type", "") + size = len(raw) + ext = os.path.splitext(name)[1].lower() + safe = re.sub(r"[^A-Za-z0-9._\-]", "_", name) + saved = os.path.join( + "temp", "uploaded", + f"{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{safe}", + ) + try: + with open(saved, "wb") as out: + out.write(raw) + except Exception: + saved = "(保存失败)" + + if mime.startswith("image/"): + b64 = base64.b64encode(raw).decode() + attachment_chunks.append( + f"\n- [图片附件] {name} ({size} bytes)\n 磁盘路径: {saved}" + f"\n data:{mime};base64,{b64}" + ) + display_attachments.append({"type": "image", "name": name}) + img_count += 1 + elif ext in TEXT_FILE_EXTS: + text = raw.decode("utf-8", errors="replace") + attachment_chunks.append( + f"\n--- 文本文件: {name} ({size} bytes) ---\n磁盘路径: {saved}\n{text[:MAX_INLINE_CHARS]}" + + ("\n[内容已截断,请用 file_read 读取完整内容]" if len(text) > MAX_INLINE_CHARS else "") + ) + display_attachments.append({"type": "file", "name": name}) + file_names.append(name) + else: + attachment_chunks.append( + f"\n- 文件: {name} ({size} bytes)\n 磁盘路径: {saved}" + ) + display_attachments.append({"type": "file", "name": name}) + file_names.append(name) + + parts = [] + if img_count: + parts.append(f"{img_count} 张图片") + if file_names: + parts.append(f"{len(file_names)} 个文件({'、'.join(file_names)})") + display_prompt = f"{prompt}\n\n📎 已附带:{','.join(parts)}" if parts else prompt + return prompt + "\n".join(attachment_chunks), display_prompt, display_attachments + + +# ── small reusable widgets ──────────────────────────────────────────────────── +class _Separator(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedHeight(1) + self.setStyleSheet(f"background: {C['border'].name()};") + + +class _Badge(QLabel): + def __init__(self, text: str, parent=None): + super().__init__(text, parent) + self.setStyleSheet( + "QLabel { background: rgba(63,63,70,0.9); color: #a1a1aa;" + " border: 1px solid #3f3f46; border-radius: 9px;" + " padding: 1px 8px; font-size: 11px; }" + ) + + +class _StreamingBadge(QLabel): + def __init__(self, parent=None): + super().__init__("处理中…", parent) + self.setStyleSheet( + "QLabel { background: rgba(124,58,237,0.18); color: #c4b5fd;" + " border: 1px solid rgba(124,58,237,0.35); border-radius: 9px;" + " padding: 1px 8px; font-size: 11px; }" + ) + self.hide() + + +class _FoldableTextBrowser(QTextBrowser): + """QTextBrowser subclass that reliably detects clicks on fold anchors.""" + def __init__(self, parent=None): + super().__init__(parent) + self.viewport().installEventFilter(self) + + def eventFilter(self, obj, event): + from PySide6.QtCore import QEvent + if obj is self.viewport() and event.type() == QEvent.MouseButtonRelease: + href = self.anchorAt(event.pos()) + if href and href.startswith("#fold_"): + from urllib.parse import unquote + title = unquote(href[6:]) + p = self.parent() + while p and not isinstance(p, _MsgRow): + p = p.parent() + if p and hasattr(p, '_toggle_fold'): + p._toggle_fold(title) + return True + return super().eventFilter(obj, event) + + +class _MsgRow(QWidget): + """A single message row – flat layout with avatar, inspired by ChatGPT / Qwen.""" + + _ACTION_BTN = """ + QPushButton { + background: transparent; border: none; border-radius: 4px; padding: 3px; + } + QPushButton:hover { background: %s; } + """ % C["hover_bg"] + def __init__(self, text: str, role: str, parent=None, on_resend=None, on_delete=None, on_rewrite=None, created_at: str = None): + super().__init__(parent) + self._text = text + self._role = role + self._on_resend = on_resend + self._on_delete = on_delete + self._on_rewrite = on_rewrite + self._created_at = created_at + self._action_row = None + self._finished = True + + is_user = role == "user" + self.setStyleSheet("background: transparent;") + + outer = QHBoxLayout(self) + outer.setContentsMargins(12, 10, 12, 10) + outer.setSpacing(10) + outer.setAlignment(Qt.AlignTop) + + # ── avatar ── + avatar = QLabel() + avatar.setFixedSize(30, 30) + avatar.setAlignment(Qt.AlignCenter) + svg_data = _SVG_USER if is_user else _SVG_BOT + avatar_color = "#c8c8d0" if is_user else "#9eb4d0" + pm = QPixmap(30, 30) + pm.fill(QColor(0, 0, 0, 0)) + from PySide6.QtSvg import QSvgRenderer + renderer = QSvgRenderer(QByteArray(svg_data.replace("{c}", avatar_color).encode())) + p = QPainter(pm) + renderer.render(p) + p.end() + avatar.setPixmap(pm) + avatar.setStyleSheet( + "QLabel { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.10);" + " border-radius: 15px; }" + ) + + # ── content column ── + content_col = QVBoxLayout() + content_col.setContentsMargins(0, 0, 0, 0) + content_col.setSpacing(2) + + role_lbl = QLabel("你" if is_user else "助手") + role_lbl.setStyleSheet( + "color: #d4d4d8; font-size: 12px; font-weight: 700; background: transparent;" + ) + if is_user: + role_lbl.setAlignment(Qt.AlignRight) + content_col.addWidget(role_lbl) + + if is_user: + # ── user: right-aligned bubble ── + bubble = QWidget() + bubble.setStyleSheet( + "background: rgba(63,63,70,0.4); border-radius: 12px;" + ) + bubble_ly = QVBoxLayout(bubble) + bubble_ly.setContentsMargins(12, 8, 12, 8) + bubble_ly.setSpacing(0) + + label = QLabel(text) + label.setWordWrap(True) + label.setTextInteractionFlags(Qt.TextSelectableByMouse) + label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + label.setStyleSheet( + "QLabel { background: transparent; color: #e4e4e7;" + " padding: 0; font-size: 14px; line-height: 1.6; }" + ) + bubble_ly.addWidget(label) + self._label = label + + # Size bubble to text: measure longest line, cap at 420 + fm = label.fontMetrics() + text_w = max((fm.horizontalAdvance(ln) for ln in text.split('\n')), default=0) + bubble.setMinimumWidth(min(text_w + 24, 420)) + bubble.setMaximumWidth(420) + content_col.addWidget(bubble, 0, Qt.AlignRight) + + # ── user message action row ── + self._action_row = QWidget() + self._action_row.setStyleSheet("background: transparent;") + alayout = QHBoxLayout(self._action_row) + alayout.setContentsMargins(0, 4, 0, 0) + alayout.setSpacing(4) + alayout.setAlignment(Qt.AlignRight) + + icon_sz = QSize(15, 15) + + copy_btn = QPushButton() + copy_btn.setIcon(_svg_icon("copy", _SVG_COPY)) + copy_btn.setIconSize(icon_sz) + copy_btn.setFixedSize(26, 24) + copy_btn.setStyleSheet(self._ACTION_BTN) + copy_btn.setToolTip("复制") + copy_btn.setCursor(QCursor(Qt.PointingHandCursor)) + copy_btn.clicked.connect(self._copy_text) + alayout.addWidget(copy_btn) + + if on_delete: + delete_btn = QPushButton() + delete_btn.setIcon(_svg_icon("delete", _SVG_TRASH)) + delete_btn.setIconSize(icon_sz) + delete_btn.setFixedSize(26, 24) + delete_btn.setStyleSheet(self._ACTION_BTN) + delete_btn.setToolTip("删除") + delete_btn.setCursor(QCursor(Qt.PointingHandCursor)) + delete_btn.clicked.connect(self._do_delete) + alayout.addWidget(delete_btn) + + if on_rewrite: + rewrite_btn = QPushButton() + rewrite_btn.setIcon(_svg_icon("rewrite", _SVG_RESET)) + rewrite_btn.setIconSize(icon_sz) + rewrite_btn.setFixedSize(26, 24) + rewrite_btn.setStyleSheet(self._ACTION_BTN) + rewrite_btn.setToolTip("重写") + rewrite_btn.setCursor(QCursor(Qt.PointingHandCursor)) + rewrite_btn.clicked.connect(self._do_rewrite) + alayout.addWidget(rewrite_btn) + + alayout.addStretch() + + if created_at: + from datetime import datetime + try: + dt = datetime.fromisoformat(created_at) + time_lbl = QLabel(dt.strftime("%Y-%m-%d %H:%M")) + time_lbl.setStyleSheet("color: #a1a1aa; font-size: 11px; background: transparent;") + alayout.addWidget(time_lbl) + except: + pass + + self._action_row.hide() + content_col.addWidget(self._action_row, 0, Qt.AlignRight) + else: + # ── assistant: left-aligned, no bubble ── + browser = _FoldableTextBrowser() + browser.setReadOnly(True) + browser.setOpenExternalLinks(True) + browser.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + browser.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + browser.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + browser.document().setDefaultStyleSheet(_MD_CSS) + browser.setStyleSheet( + "QTextBrowser { background: transparent; color: #e4e4e7;" + " border: none; padding: 0; font-size: 14px; }" + ) + self._folded_ids = set() # 记录被折叠的块 + self._auto_fold_new_blocks(text) + browser.setHtml(self._render_with_folds(text)) + self._label = browser + content_col.addWidget(browser) + self._adjust_browser_height() + + self._action_row = QWidget() + self._action_row.setStyleSheet("background: transparent;") + alayout = QHBoxLayout(self._action_row) + alayout.setContentsMargins(0, 4, 0, 0) + alayout.setSpacing(4) + + icon_sz = QSize(15, 15) + + copy_btn = QPushButton() + copy_btn.setIcon(_svg_icon("copy", _SVG_COPY)) + copy_btn.setIconSize(icon_sz) + copy_btn.setFixedSize(26, 24) + copy_btn.setStyleSheet(self._ACTION_BTN) + copy_btn.setToolTip("复制") + copy_btn.setCursor(QCursor(Qt.PointingHandCursor)) + copy_btn.clicked.connect(self._copy_text) + alayout.addWidget(copy_btn) + + if on_delete: + delete_btn = QPushButton() + delete_btn.setIcon(_svg_icon("delete", _SVG_TRASH)) + delete_btn.setIconSize(icon_sz) + delete_btn.setFixedSize(26, 24) + delete_btn.setStyleSheet(self._ACTION_BTN) + delete_btn.setToolTip("删除") + delete_btn.setCursor(QCursor(Qt.PointingHandCursor)) + delete_btn.clicked.connect(self._do_delete) + alayout.addWidget(delete_btn) + + if on_resend: + regen_btn = QPushButton() + regen_btn.setIcon(_svg_icon("regen", _SVG_REGEN)) + regen_btn.setIconSize(icon_sz) + regen_btn.setFixedSize(26, 24) + regen_btn.setStyleSheet(self._ACTION_BTN) + regen_btn.setToolTip("重新生成") + regen_btn.setCursor(QCursor(Qt.PointingHandCursor)) + regen_btn.clicked.connect(self._do_resend) + alayout.addWidget(regen_btn) + + export_btn = QPushButton() + export_btn.setIcon(_svg_icon("save", _SVG_SAVE)) + export_btn.setIconSize(icon_sz) + export_btn.setFixedSize(26, 24) + export_btn.setStyleSheet(self._ACTION_BTN) + export_btn.setToolTip("导出为md") + export_btn.setCursor(QCursor(Qt.PointingHandCursor)) + export_btn.clicked.connect(self._export_as_md) + alayout.addWidget(export_btn) + + alayout.addStretch() + + if created_at: + from datetime import datetime + try: + dt = datetime.fromisoformat(created_at) + time_lbl = QLabel(dt.strftime("%Y-%m-%d %H:%M")) + time_lbl.setStyleSheet("color: #a1a1aa; font-size: 11px; background: transparent;") + alayout.addWidget(time_lbl) + except: + pass + + self._action_row.hide() + content_col.addWidget(self._action_row) + + # ── assemble: assistant left, user right ── + if is_user: + outer.addStretch(1) + outer.addLayout(content_col, 0) + outer.addWidget(avatar, 0, Qt.AlignTop) + else: + outer.addWidget(avatar, 0, Qt.AlignTop) + outer.addLayout(content_col, 1) + + def _copy_text(self): + QApplication.clipboard().setText(self._text) + + def _do_resend(self): + if self._on_resend: + self._on_resend() + + def _do_delete(self): + if self._on_delete: + self._on_delete() + + def _do_rewrite(self): + if self._on_rewrite: + self._on_rewrite() + + def _export_as_md(self): + from PySide6.QtWidgets import QFileDialog + import os + from datetime import datetime + default_name = f"msg_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md" + file_path, _ = QFileDialog.getSaveFileName( + self, "导出为 Markdown", default_name, "Markdown 文件 (*.md);;所有文件 (*)" + ) + if file_path: + try: + with open(file_path, "w", encoding="utf-8") as f: + f.write(self._text) + except Exception as e: + import traceback + traceback.print_exc() + + def enterEvent(self, event): + if self._action_row and self._finished: + self._action_row.show() + super().enterEvent(event) + + def leaveEvent(self, event): + if self._action_row: + self._action_row.hide() + super().leaveEvent(event) + + def resizeEvent(self, event): + super().resizeEvent(event) + if self._role != "user" and hasattr(self, '_label'): + self._adjust_browser_height() + + def set_finished(self, done: bool): + self._finished = done + if not done and self._action_row: + self._action_row.hide() + + def _adjust_browser_height(self): + doc = self._label.document() + w = self._label.width() + if w < 50: + w = 460 + doc.setTextWidth(w - 6) + self._label.setFixedHeight(int(doc.size().height() + 8)) + + def set_text(self, text: str): + self._text = text + if self._role == "user": + self._label.setText(text) + self._label.adjustSize() + else: + self._auto_fold_new_blocks(text) + self._label.setHtml(self._render_with_folds(text)) + self._adjust_browser_height() + + def highlight(self, keyword: str): + """Apply highlight and return keyword's y position in document, or None.""" + if not keyword or not self._text: + return None + kw_lower = keyword.lower() + text_lower = self._text.lower() + if kw_lower not in text_lower: + return None + if self._role == "user": + escaped = self._text.replace("&", "&").replace("<", "<").replace(">", ">") + kw_esc = keyword.replace("&", "&").replace("<", "<").replace(">", ">") + highlighted = escaped.replace(kw_esc, f'{kw_esc}') + self._label.setText(highlighted) + self._label.adjustSize() + return 0 # plain text, keyword at top + else: + from PySide6.QtGui import QTextDocument, QTextCursor, QTextCharFormat + doc = self._label.document() + cursor = QTextCursor(doc) + flags = QTextDocument.FindFlags(0) + fmt = QTextCharFormat() + fmt.setBackground(QColor(251, 191, 36, 90)) + fmt.setForeground(QColor(251, 191, 36)) + keyword_y = None + while True: + cursor = doc.find(keyword, cursor, flags) + if cursor.isNull(): + break + cursor.mergeCharFormat(fmt) + if keyword_y is None: + keyword_y = self._label.cursorRect(cursor).y() + self._adjust_browser_height() + return keyword_y + + def clear_highlight(self): + if self._role == "user": + self._label.setText(self._text) + self._label.adjustSize() + else: + self._label.setHtml(self._render_with_folds(self._text)) + self._adjust_browser_height() + + + def _parse_foldable_blocks(self, text: str): + """解析文本为可折叠块,返回 [(type, title_or_None, content), ...]""" + import re + lines = text.split('\n') + blocks = [] + current_type = "normal" + current_title = None + current_lines = [] + + for line in lines: + # 检查是否是折叠块开始 + llm_match = re.match(r'^\s*\*\*LLM Running \(Turn \d+\) \.\.\.\*\*\s*$', line) + tool_match = re.match(r'^\s*🛠️\s*Tool:', line) + tool_compact_match = re.match(r'^\s*🛠️\s+\w+\(', line) + + is_foldable_start = llm_match or tool_match or tool_compact_match + + if is_foldable_start: + if current_lines: + blocks.append((current_type, current_title, '\n'.join(current_lines))) + + title = line.strip() + if llm_match: + title = line.strip().replace('**', '') + current_type = "foldable" + current_title = title + current_lines = [line] + else: + current_lines.append(line) + + if current_lines: + blocks.append((current_type, current_title, '\n'.join(current_lines))) + + return blocks + + def _auto_fold_new_blocks(self, text: str): + """将新出现的折叠块加入 _folded_ids(仅在此处修改集合)""" + for _, title, _ in self._parse_foldable_blocks(text): + if title is not None and title not in self._folded_ids: + self._folded_ids.add(title) + + def _render_with_folds(self, text: str) -> str: + """渲染文本为带折叠的 HTML(纯渲染,不修改 _folded_ids)""" + from urllib.parse import quote + blocks = self._parse_foldable_blocks(text) + html_parts = [] + + for i, (block_type, title, content) in enumerate(blocks): + if block_type == "normal": + html_parts.append(f'
{_md_to_html(content)}
') + else: + safe_title = quote(title, safe='') + display_title = title.replace('**', '') + if title in self._folded_ids: + # 折叠状态:只显示标题 + 展开链接 + html_parts.append( + f'' + ) + else: + # 展开状态:显示标题 + 折叠链接 + 内容 + html_parts.append( + f'' + ) + return '\n'.join(html_parts) + + def _toggle_fold(self, title): + """折叠/展开切换""" + if title in self._folded_ids: + self._folded_ids.remove(title) + else: + self._folded_ids.add(title) + self._label.setHtml(self._render_with_folds(self._text)) + self._adjust_browser_height() + + +class _TabButton(QPushButton): + _STYLE = """ + QPushButton {{ + background: transparent; color: {muted}; + border: none; border-radius: 8px; + padding: 0 14px; font-size: 12px; font-weight: 700; + }} + QPushButton:hover {{ + background: {hover_bg}; color: {text}; + }} + QPushButton:checked {{ + background: {accent}; color: white; + }} + """.format(muted=C["muted"], text=C["text"], hover_bg=C["hover_bg"], accent=C["accent"]) + + def __init__(self, text: str, parent=None): + super().__init__(text, parent) + self.setCheckable(True) + self.setFixedHeight(30) + self.setStyleSheet(self._STYLE) + + +def _action_btn(label: str, color: str, icon: QIcon | None = None) -> QPushButton: + btn = QPushButton(label) + if icon and not icon.isNull(): + btn.setIcon(icon) + btn.setIconSize(QSize(16, 16)) + btn.setFixedHeight(36) + btn.setStyleSheet(f""" + QPushButton {{ + background: rgba(35,35,40,0.8); color: {C['text']}; + border: 1px solid {C['border'].name()}; + border-left: 3px solid {color}; + border-radius: 8px; padding: 0 14px; + font-size: 13px; font-weight: 700; text-align: left; + }} + QPushButton:hover {{ background: rgba(55,55,62,0.9); }} + QPushButton:checked {{ color: {color}; background: rgba(35,35,40,0.95); }} + """) + return btn + + +# ── Main panel ──────────────────────────────────────────────────────────────── +class ChatPanel(QWidget): + """Frameless always-on-top chat window.""" + + def __init__(self, agent): + super().__init__() + self.agent = agent + + # session state + self._messages: list[dict] = [] + self._session = {"id": _make_session_id(), "title": "新对话", "messages": []} + self._history: list[dict] = _load_history() + self._pending_files: list[dict] = [] # {'name','type','raw'} + self._settings_health_checked = False + + # streaming state + self._display_queue: Optional[_queue.Queue] = None + self._streaming_row: Optional[_MsgRow] = None + self._streaming_text = "" + self._user_scrolled_up = False + self._poll_timer = QTimer(self) + self._poll_timer.timeout.connect(self._poll_queue) + + # autonomous mode + self.autonomous_enabled = False + self.last_reply_time = time.time() + + self.setWindowFlags( + Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + ) + self.setAttribute(Qt.WA_TranslucentBackground) + self.resize(530, 700) + + # drag state (title bar) + self._drag_pos: Optional[QPoint] = None + + self._build_ui() + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + path = QPainterPath() + path.addRect(0.5, 0.5, self.width() - 1.0, self.height() - 1.0) + grad = QLinearGradient(0, 0, 0, self.height()) + grad.setColorAt(0.0, QColor(20, 20, 28, 255)) + grad.setColorAt(1.0, QColor(10, 10, 14, 255)) + p.fillPath(path, grad) + + def resizeEvent(self, event): + path = QPainterPath() + path.addRect(0, 0, float(self.width()), float(self.height())) + self.setMask(QRegion(path.toFillPolygon().toPolygon())) + super().resizeEvent(event) + + # ── UI construction ─────────────────────────────────────────────────────── + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + root.addWidget(self._build_titlebar()) + root.addWidget(_Separator()) + root.addWidget(self._build_tabbar()) + root.addWidget(_Separator()) + + self._stack = QStackedWidget() + self._stack.setStyleSheet("background: transparent;") + self._stack.addWidget(self._build_chat_page()) # 0 + self._stack.addWidget(self._build_history_page()) # 1 + self._stack.addWidget(self._build_sop_page()) # 2 + self._stack.addWidget(self._build_settings_page())# 3 + root.addWidget(self._stack) + root.addWidget(self._build_statusbar()) + + # Now that _stack exists, activate the first tab + self._switch_tab(0) + + # ── title bar ───────────────────────────────────────────────────────────── + def _build_titlebar(self) -> QWidget: + bar = QWidget() + bar.setFixedHeight(48) + bar.setStyleSheet("background: transparent;") + bar.setCursor(QCursor(Qt.SizeAllCursor)) + + ly = QHBoxLayout(bar) + ly.setContentsMargins(16, 0, 10, 0) + ly.setSpacing(8) + + # Search button + search_btn = QPushButton() + search_btn.setIcon(_svg_icon("search", _SVG_SEARCH, "#a1a1aa")) + search_btn.setIconSize(QSize(16, 16)) + search_btn.setFixedSize(26, 26) + search_btn.setCursor(QCursor(Qt.PointingHandCursor)) + search_btn.setStyleSheet(""" + QPushButton { background: transparent; border: none; border-radius: 13px; } + QPushButton:hover { background: rgba(63,63,70,0.6); } + """) + search_btn.clicked.connect(self._toggle_search) + self._search_btn = search_btn + ly.addWidget(search_btn) + + # Search widget (hidden by default) + self._search_widget = QWidget() + self._search_widget.hide() + sw_ly = QHBoxLayout(self._search_widget) + sw_ly.setContentsMargins(0, 0, 0, 0) + sw_ly.setSpacing(6) + + self._search_input = QLineEdit() + self._search_input.setPlaceholderText("搜索当前对话和历史...") + self._search_input.setFixedHeight(26) + self._search_input.setStyleSheet(f""" + QLineEdit {{ + background: rgba(32,32,38,0.9); + border: 1px solid {C['border'].name()}; + border-radius: 13px; + color: {C['text']}; + font-size: 13px; + padding: 0 10px; + }} + QLineEdit::placeholder {{ color: {C['muted']}; }} + """) + self._search_input.setFixedWidth(200) + self._search_input.textChanged.connect(self._on_search_changed) + self._search_input.installEventFilter(self) + sw_ly.addWidget(self._search_input) + + close_search = QPushButton("×") + close_search.setFixedSize(26, 26) + close_search.setCursor(QCursor(Qt.PointingHandCursor)) + close_search.setStyleSheet(""" + QPushButton { background: transparent; color: #71717a; border: none; font-size: 16px; } + QPushButton:hover { color: #a1a1aa; } + """) + close_search.clicked.connect(self._hide_search) + sw_ly.addWidget(close_search) + ly.addWidget(self._search_widget) + + ly.addStretch() + + # Minimize button + mini = QPushButton("\uE949") + mini.setFixedSize(26, 26) + mini.setCursor(QCursor(Qt.PointingHandCursor)) + mini.setStyleSheet(""" + QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; + border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; } + QPushButton:hover { background: rgba(63,63,70,0.9); color: white; } + """) + mini.clicked.connect(self.hide) + ly.addWidget(mini) + + # Maximize button + maxi = QPushButton("\uE739") + maxi.setFixedSize(26, 26) + maxi.setCursor(QCursor(Qt.PointingHandCursor)) + maxi.setStyleSheet(""" + QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; + border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; } + QPushButton:hover { background: rgba(63,63,70,0.9); color: white; } + """) + maxi.clicked.connect(self._toggle_maximize) + self._maxi_btn = maxi + ly.addWidget(maxi) + + # Close button + close = QPushButton("\uE8BB") + close.setFixedSize(26, 26) + close.setCursor(QCursor(Qt.PointingHandCursor)) + close.setStyleSheet(""" + QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; + border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; } + QPushButton:hover { background: rgba(220,38,38,0.85); color: white; } + """) + close.clicked.connect(lambda: (self.close(), QApplication.instance().quit())) + ly.addWidget(close) + + # Drag + bar.mousePressEvent = self._tb_press + bar.mouseMoveEvent = self._tb_move + bar.mouseReleaseEvent = self._tb_release + return bar + + def _toggle_search(self): + if hasattr(self, "_search_visible") and self._search_visible: + self._hide_search() + else: + self._show_search() + + def _show_search(self): + self._search_visible = True + self._search_btn.setFixedSize(0, 0) + self._search_widget.show() + self._search_input.setFocus() + self._search_input.selectAll() + + def _hide_search(self): + self._search_visible = False + self._search_btn.setFixedSize(26, 26) + self._search_widget.hide() + self._search_input.clear() + self._clear_all_highlights() + if self._stack.currentIndex() == 1: + self._reset_history_items_style() + + def _hide_search_if_no_focus(self): + if not self._search_input.hasFocus(): + self._hide_search() + + def _on_search_changed(self, text): + if not text.strip(): + self._clear_all_highlights() + return + keyword = text.strip() + current_tab = self._stack.currentIndex() + + if current_tab == 0: + self._search_current_chat(keyword) + elif current_tab == 1: + self._search_history(keyword) + + def _clear_all_highlights(self): + for i in range(self._msg_layout.count() - 1): + w = self._msg_layout.itemAt(i).widget() + if isinstance(w, _MsgRow): + w.clear_highlight() + + def _search_current_chat(self, keyword: str): + first_found = None + first_keyword_y = None + for i in range(self._msg_layout.count() - 1): + w = self._msg_layout.itemAt(i).widget() + if isinstance(w, _MsgRow): + if keyword.lower() in w._text.lower(): + kw_y = w.highlight(keyword) + if first_found is None: + first_found = w + first_keyword_y = kw_y + else: + w.clear_highlight() + # 滚动到第一个匹配项(使用关键词在文档内的实际位置) + if first_found: + self._scroll_to_widget(first_found, first_keyword_y or 0) + + def _scroll_to_widget(self, w, keyword_y=0): + self._user_scrolled_up = True + self._msg_container.layout().activate() + QApplication.processEvents() + + sb = self._scroll.verticalScrollBar() + vp_h = self._scroll.viewport().height() + keyword_screen_y = w.y() + keyword_y + target = keyword_screen_y - vp_h // 3 + target = max(0, min(target, sb.maximum())) + sb.setValue(target) + QApplication.processEvents() + self._scroll.viewport().repaint() + + def _search_history(self, keyword: str): + kw_lower = keyword.lower() + for i in range(self._hist_list.count()): + item = self._hist_list.item(i) + session = item.data(Qt.UserRole) + messages = session.get("messages", []) if session else [] + content_text = " ".join([m.get("content", "") for m in messages if isinstance(m.get("content"), str)]) + match = kw_lower in content_text.lower() + item.setHidden(not match) + if match: + item.setBackground(QColor(251, 191, 36, 50)) + item.setForeground(QColor(251, 191, 36)) + else: + item.setBackground(QColor(0, 0, 0, 0)) + item.setForeground(QColor(255, 255, 255)) + + def _reset_history_items_style(self): + for i in range(self._hist_list.count()): + item = self._hist_list.item(i) + item.setHidden(False) + item.setBackground(QColor(0, 0, 0, 0)) + item.setForeground(QColor(255, 255, 255)) + w = self._hist_list.itemWidget(item) + if w: + w.setStyleSheet( + f"background: rgba(35,35,42,0.6); color: {C['text']};" + " border: 1px solid #3f3f46; border-radius: 8px;" + " padding: 8px 12px; margin: 2px 0;" + ) + + def _tb_press(self, e): + if e.button() == Qt.LeftButton: + self._drag_pos = e.globalPosition().toPoint() - self.pos() + + def _tb_move(self, e): + if e.buttons() == Qt.LeftButton and self._drag_pos is not None: + self.move(e.globalPosition().toPoint() - self._drag_pos) + + def _tb_release(self, _e): + self._drag_pos = None + + def _toggle_maximize(self): + if self.isMaximized(): + self.showNormal() + self._maxi_btn.setText("☐") + else: + self.showMaximized() + self._maxi_btn.setText("❐") + + # ── status bar ───────────────────────────────────────────────────────────── + def _build_statusbar(self) -> QWidget: + bar = QWidget() + bar.setFixedHeight(24) + bar.setStyleSheet("background: transparent;") + ly = QHBoxLayout(bar) + ly.setContentsMargins(16, 0, 10, 0) + ly.setSpacing(8) + + # Status dot + dot = QLabel("●") + dot.setStyleSheet(f"color: {C['green']}; font-size: 9px;") + dot.setFixedWidth(12) + ly.addWidget(dot) + + # Model name (clickable to show model list) + self._model_badge = QLabel(self._model_name()) + self._model_badge.setStyleSheet("color: #a1a1aa; font-size: 11px;") + self._model_badge.setCursor(QCursor(Qt.PointingHandCursor)) + self._model_badge.mousePressEvent = lambda e: self._show_model_menu(e) + ly.addWidget(self._model_badge) + + self._streaming_badge = _StreamingBadge() + ly.addWidget(self._streaming_badge) + + ly.addStretch() + return bar + + def _show_model_menu(self, _e): + menu = QMenu(self._model_badge) + menu.setStyleSheet(f""" + QMenu {{ + background: {C['panel'].name()}; + border: 1px solid {C['border'].name()}; + padding: 4px 0; + }} + QMenu::item {{ + color: {C['text']}; + padding: 6px 20px 6px 12px; + font-size: 12px; + }} + QMenu::item:selected {{ + background: {C['hover_bg']}; + }} + """) + for i, client in enumerate(self.agent.llmclients): + name = getattr(client, 'name', None) or "未知" + act = menu.addAction(f"{name} #{i + 1}") + act.triggered.connect(lambda _, idx=i: self._do_switch_to(idx)) + menu.exec(QCursor.pos()) + + # ── tab bar ─────────────────────────────────────────────────────────────── + def _build_tabbar(self) -> QWidget: + bar = QWidget() + bar.setFixedHeight(40) + bar.setStyleSheet("background: rgba(10,10,14,0.6);") + + ly = QHBoxLayout(bar) + ly.setContentsMargins(12, 5, 12, 5) + ly.setSpacing(4) + + self._tabs: list[_TabButton] = [] + tab_defs = [ + (_SVG_CHAT, "对话"), + (_SVG_CLOCK, "历史"), + (_SVG_BOOK, "SOP"), + (_SVG_GEAR, "设置"), + ] + for i, (svg, text) in enumerate(tab_defs): + btn = _TabButton(text) + btn.setIcon(_svg_icon(text, svg, "#b0b0b8")) + btn.setIconSize(QSize(14, 14)) + btn.clicked.connect(lambda _checked, idx=i: self._switch_tab(idx)) + ly.addWidget(btn) + self._tabs.append(btn) + + ly.addStretch() + + new_btn = QPushButton("新对话") + new_btn.setIcon(_svg_icon("plus", _SVG_PLUS, "#a78bfa")) + new_btn.setIconSize(QSize(12, 12)) + new_btn.setFixedHeight(27) + new_btn.setStyleSheet(f""" + QPushButton {{ background: rgba(124,58,237,0.18); color: #a78bfa; + border: 1px solid rgba(124,58,237,0.3); border-radius: 7px; + padding: 0 10px; font-size: 12px; font-weight: 700; }} + QPushButton:hover {{ background: rgba(124,58,237,0.35); color: white; }} + """) + new_btn.clicked.connect(self._new_session) + ly.addWidget(new_btn) + + # NOTE: _switch_tab(0) is called in _build_ui() after _stack is created + return bar + + def _switch_tab(self, idx: int): + self._stack.setCurrentIndex(idx) + for i, btn in enumerate(self._tabs): + btn.setChecked(i == idx) + # 切换标签时关闭搜索框 + if hasattr(self, '_search_visible') and self._search_visible: + self._hide_search() + if idx == 1: + self._refresh_history() + if idx == 2: + self._refresh_sop() + if idx == 3: + self._refresh_model_rows_style() + if not self._settings_health_checked: + self._start_health_checks() + self._settings_health_checked = True + + # ── chat page ───────────────────────────────────────────────────────────── + def _build_chat_page(self) -> QWidget: + page = QWidget() + page.setStyleSheet("background: transparent;") + ly = QVBoxLayout(page) + ly.setContentsMargins(0, 0, 0, 0) + ly.setSpacing(0) + + # ── message scroll area ── + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll.setStyleSheet(f"QScrollArea {{ background: transparent; border: none; }} {SCROLLBAR_STYLE}") + + self._msg_container = QWidget() + self._msg_container.setStyleSheet("background: transparent;") + self._msg_layout = QVBoxLayout(self._msg_container) + self._msg_layout.setContentsMargins(0, 12, 0, 12) + self._msg_layout.setSpacing(4) + self._msg_layout.addStretch() + + self._scroll.setWidget(self._msg_container) + self._scroll.verticalScrollBar().valueChanged.connect(self._on_scroll) + + # ── scroll navigation buttons (centered at bottom of message area) ── + scroll_wrapper = QWidget() + scroll_wrapper.setStyleSheet("background: transparent;") + wrap_ly = QVBoxLayout(scroll_wrapper) + wrap_ly.setContentsMargins(0, 0, 0, 0) + wrap_ly.setSpacing(0) + wrap_ly.addWidget(self._scroll) + + self._nav_widget = QWidget() + self._nav_widget.setFixedSize(68, 28) + self._nav_widget.setStyleSheet("background: transparent; border: none;") + nav_ly = QHBoxLayout(self._nav_widget) + nav_ly.setContentsMargins(6, 2, 6, 2) + nav_ly.setSpacing(8) + + self._nav_up = QPushButton("∧") + self._nav_up.setFixedWidth(26) + self._nav_up.setCursor(QCursor(Qt.PointingHandCursor)) + self._nav_up.setStyleSheet(""" + QPushButton { background: transparent; color: #71717a; border: none; font-size: 14px; } + QPushButton:hover { color: #a1a1aa; } + QPushButton:disabled { color: #27272a; } + """) + self._nav_up.clicked.connect(self._scroll_to_top) + + self._nav_down = QPushButton("∨") + self._nav_down.setFixedWidth(26) + self._nav_down.setCursor(QCursor(Qt.PointingHandCursor)) + self._nav_down.setStyleSheet(""" + QPushButton { background: transparent; color: #71717a; border: none; font-size: 14px; } + QPushButton:hover { color: #a1a1aa; } + QPushButton:disabled { color: #27272a; } + """) + self._nav_down.clicked.connect(self._scroll_to_bottom) + + nav_ly.addWidget(self._nav_up) + nav_ly.addWidget(self._nav_down) + + wrap_ly.addWidget(self._nav_widget, 0, Qt.AlignHCenter | Qt.AlignBottom) + self._nav_widget.setContentsMargins(0, 0, 0, 8) + self._nav_widget.hide() + + ly.addWidget(scroll_wrapper, 1) + + ly.addWidget(_Separator()) + + # ── input area ── + ly.addWidget(self._build_input_area()) + + QTimer.singleShot(200, self._update_nav_visibility) + return page + + def _build_input_area(self) -> QWidget: + wrap = QWidget() + wrap.setStyleSheet("background: transparent;") + ly = QVBoxLayout(wrap) + ly.setContentsMargins(20, 6, 20, 0) + ly.setSpacing(0) + + self._chips_row = QWidget() + self._chips_row.setStyleSheet("background: transparent;") + self._chips_ly = QHBoxLayout(self._chips_row) + self._chips_ly.setContentsMargins(0, 0, 0, 6) + self._chips_ly.setSpacing(6) + self._chips_row.hide() + ly.addWidget(self._chips_row) + + card = QWidget() + card.setStyleSheet(f""" + QWidget#inputCard {{ + background: rgba(32,32,38,0.85); + border: 1px solid {C['border'].name()}; + border-radius: 16px; + }} + QWidget#inputCard:focus-within {{ + border-color: rgba(124,58,237,0.55); + }} + """) + card.setObjectName("inputCard") + card_ly = QVBoxLayout(card) + card_ly.setContentsMargins(14, 10, 10, 10) + card_ly.setSpacing(6) + + class _PlainTextEdit(QTextEdit): + def insertFromMimeData(self, source): + text = source.text() or source.data("text/plain") + if text: + self.insertPlainText(text) + + self._input = _PlainTextEdit() + self._input.setAutoFormatting(QTextEdit.AutoNone) + self._input.setFixedHeight(64) + self._input.setPlaceholderText("给助手发送消息... Enter发送,Shift+Enter换行") + self._input.setStyleSheet(f""" + QTextEdit {{ + background: transparent; color: {C['text']}; + border: none; padding: 0; font-size: 14px; + selection-background-color: rgba(124,58,237,0.4); + }} + """) + self._input.installEventFilter(self) + self._input.textChanged.connect(self._on_text_changed) + card_ly.addWidget(self._input) + + bottom = QHBoxLayout() + bottom.setSpacing(6) + + attach = QPushButton() + attach.setIcon(_svg_icon("clip", _SVG_CLIP, "#a1a1aa")) + attach.setIconSize(QSize(17, 17)) + attach.setFixedSize(30, 30) + attach.setToolTip("上传附件") + attach.setCursor(QCursor(Qt.PointingHandCursor)) + attach.setStyleSheet(""" + QPushButton { background: transparent; border: none; border-radius: 15px; } + QPushButton:hover { background: rgba(63,63,70,0.6); } + """) + attach.clicked.connect(self._attach_files) + bottom.addWidget(attach) + + self._char_lbl = QLabel("0 / 2000") + self._char_lbl.setStyleSheet(f"color: {C['muted']}; font-size: 11px;") + bottom.addWidget(self._char_lbl) + + self._token_lbl = QLabel("") + self._token_lbl.setStyleSheet(f"color: {C['muted']}; font-size: 11px; margin-left: 10px;") + bottom.addWidget(self._token_lbl) + + bottom.addStretch() + + self._is_streaming = False + self._send_btn = QPushButton() + self._send_btn.setFixedSize(34, 34) + self._send_btn.setCursor(QCursor(Qt.PointingHandCursor)) + self._send_btn.clicked.connect(self._on_send_btn_click) + self._set_send_mode() + bottom.addWidget(self._send_btn) + + card_ly.addLayout(bottom) + ly.addWidget(card) + return wrap + + # ── history page ────────────────────────────────────────────────────────── + def _build_history_page(self) -> QWidget: + page = QWidget() + page.setStyleSheet("background: transparent;") + ly = QVBoxLayout(page) + ly.setContentsMargins(12, 12, 12, 12) + ly.setSpacing(8) + + header = QHBoxLayout() + lbl = QLabel("历史记录") + lbl.setStyleSheet("color: #f4f4f5; font-weight: 600; font-size: 14px;") + header.addWidget(lbl) + header.addStretch() + + restore_btn = QPushButton("恢复会话") + restore_btn.setStyleSheet(self._small_btn_style(C["accent"])) + restore_btn.clicked.connect(self._restore_selected) + header.addWidget(restore_btn) + + del_btn = QPushButton("删除") + del_btn.setStyleSheet(self._small_btn_style("#dc2626")) + del_btn.clicked.connect(self._delete_selected) + header.addWidget(del_btn) + ly.addLayout(header) + + self._hist_list = QListWidget() + self._hist_list.setStyleSheet(f""" + QListWidget {{ background: transparent; border: none; outline: none; }} + QListWidget::item {{ + background: rgba(35,35,42,0.6); color: {C['text']}; + border: 1px solid {C['border'].name()}; border-radius: 8px; + padding: 8px 12px; margin: 2px 0; + }} + QListWidget::item:hover {{ background: rgba(55,55,65,0.8); + border-color: rgba(124,58,237,0.4); }} + QListWidget::item:selected {{ background: {C["accent_bg"]}; + border-color: rgba(124,58,237,0.6); }} + {SCROLLBAR_STYLE} + """) + self._hist_list.itemDoubleClicked.connect(self._restore_selected) + ly.addWidget(self._hist_list) + return page + + # ── SOP page ────────────────────────────────────────────────────────────── + def _build_sop_page(self) -> QWidget: + page = QWidget() + page.setStyleSheet("background: transparent;") + ly = QVBoxLayout(page) + ly.setContentsMargins(0, 0, 0, 0) + + splitter = QSplitter(Qt.Horizontal) + + self._sop_list = QListWidget() + self._sop_list.setMaximumWidth(175) + self._sop_list.setStyleSheet(f""" + QListWidget {{ background: rgba(10,10,14,0.7); border: none; + border-right: 1px solid {C['border'].name()}; outline: none; }} + QListWidget::item {{ color: {C['muted']}; padding: 7px 10px; + border-radius: 4px; margin: 1px 4px; }} + QListWidget::item:hover {{ background: rgba(55,55,65,0.7); color: {C['text']}; }} + QListWidget::item:selected {{ background: rgba(124,58,237,0.28); color: white; }} + {SCROLLBAR_STYLE} + """) + self._sop_list.currentItemChanged.connect(self._load_sop) + splitter.addWidget(self._sop_list) + + self._sop_viewer = QTextBrowser() + self._sop_viewer.setOpenExternalLinks(True) + self._sop_viewer.document().setDefaultStyleSheet(_MD_CSS) + self._sop_viewer.setStyleSheet(f""" + QTextBrowser {{ background: transparent; color: {C['text']}; + border: none; padding: 10px 14px; + font-family: "Arial", "Microsoft YaHei", sans-serif; + font-size: 13px; }} + {SCROLLBAR_STYLE} + """) + splitter.addWidget(self._sop_viewer) + splitter.setSizes([165, 340]) + ly.addWidget(splitter) + return page + + # ── settings page ───────────────────────────────────────────────────────── + def _build_settings_page(self) -> QWidget: + page = QWidget() + page.setStyleSheet("background: transparent;") + ly = QVBoxLayout(page) + ly.setContentsMargins(16, 16, 16, 16) + ly.setSpacing(8) + + lbl = QLabel("控制面板") + lbl.setStyleSheet("color: #f4f4f5; font-weight: 600; font-size: 14px;") + ly.addWidget(lbl) + + self._model_info = QLabel(f"当前模型:{self._model_name()} (#{self.agent.llm_no})") + self._model_info.setStyleSheet(f"color: {C['muted']}; font-size: 12px;") + ly.addWidget(self._model_info) + ly.addSpacing(4) + + model_hdr = QLabel("模型列表") + model_hdr.setStyleSheet("color: #d4d4d8; font-weight: 600; font-size: 13px;") + ly.addWidget(model_hdr) + + self._model_rows_container = QWidget() + self._model_rows_container.setStyleSheet("background: transparent;") + self._model_rows_layout = QVBoxLayout(self._model_rows_container) + self._model_rows_layout.setContentsMargins(0, 0, 0, 0) + self._model_rows_layout.setSpacing(3) + ly.addWidget(self._model_rows_container) + + self._model_row_widgets: list[dict] = [] + self._health_results: dict[int, bool | None] = {} + self._build_model_rows() + + ly.addSpacing(6) + + for (lbl_text, color, handler, svg) in [ + ("重置提示词", "#059669", self._do_reset_prompt, _SVG_RESET), + ("保存当前会话","#0ea5e9", self._do_save, _SVG_SAVE), + ("清空对话", "#78716c", self._do_clear, _SVG_TRASH), + ]: + b = _action_btn(lbl_text, color, _svg_icon(lbl_text, svg)) + b.clicked.connect(handler) + ly.addWidget(b) + + ly.addSpacing(10) + sep = QLabel("自主行动") + sep.setStyleSheet("color: #f4f4f5; font-weight: 600; font-size: 13px;") + ly.addWidget(sep) + + self._auto_btn = _action_btn(f"开启自主行动 (idle > {AUTO_IDLE_THRESHOLD // 60} min 自动触发)", "#f59e0b", + _svg_icon("bolt", _SVG_BOLT)) + self._auto_btn.setCheckable(True) + self._auto_btn.clicked.connect(self._do_toggle_auto) + ly.addWidget(self._auto_btn) + + trigger_btn = _action_btn("立即触发一次", "#f59e0b", + _svg_icon("play", _SVG_PLAY)) + trigger_btn.clicked.connect(self._do_trigger_auto) + ly.addWidget(trigger_btn) + + ly.addStretch() + return page + + # ── model list ──────────────────────────────────────────────────────────── + _MODEL_ROW_STYLE = ( + "QPushButton { background: rgba(39,39,42,0.7); color: #e4e4e7;" + " border: 1px solid #3f3f46; border-radius: 8px;" + " padding: 6px 10px; font-size: 12px; font-weight: 700; text-align: left; }" + " QPushButton:hover { background: rgba(63,63,70,0.8); }" + ) + _MODEL_ROW_ACTIVE = ( + "QPushButton { background: rgba(124,58,237,0.25); color: #c4b5fd;" + " border: 1px solid rgba(124,58,237,0.5); border-radius: 8px;" + " padding: 6px 10px; font-size: 12px; font-weight: 700; text-align: left; }" + " QPushButton:hover { background: rgba(124,58,237,0.35); }" + ) + + def _build_model_rows(self): + while self._model_rows_layout.count(): + w = self._model_rows_layout.takeAt(0).widget() + if w: + w.deleteLater() + self._model_row_widgets.clear() + + for idx, tc in enumerate(self.agent.llmclients): + b = tc.backend + name = f"{type(b).__name__}/{b.model}" + is_current = idx == self.agent.llm_no + + row = QWidget() + row.setStyleSheet("background: transparent;") + rlay = QHBoxLayout(row) + rlay.setContentsMargins(0, 0, 0, 0) + rlay.setSpacing(6) + + dot = QLabel("●") + dot.setFixedWidth(14) + dot.setAlignment(Qt.AlignCenter) + dot.setStyleSheet("color: #71717a; font-size: 11px;") + rlay.addWidget(dot) + + btn = QPushButton(f" #{idx} {name}") + btn.setCursor(QCursor(Qt.PointingHandCursor)) + btn.setStyleSheet(self._MODEL_ROW_ACTIVE if is_current else self._MODEL_ROW_STYLE) + btn.clicked.connect(lambda checked, i=idx: self._do_switch_to(i)) + rlay.addWidget(btn, 1) + + self._model_rows_layout.addWidget(row) + self._model_row_widgets.append({"dot": dot, "btn": btn, "idx": idx}) + + def _refresh_model_rows_style(self): + for entry in self._model_row_widgets: + is_current = entry["idx"] == self.agent.llm_no + entry["btn"].setStyleSheet( + self._MODEL_ROW_ACTIVE if is_current else self._MODEL_ROW_STYLE + ) + status = self._health_results.get(entry["idx"]) + if status is True: + entry["dot"].setStyleSheet("color: #22c55e; font-size: 11px;") + elif status is False: + entry["dot"].setStyleSheet("color: #ef4444; font-size: 11px;") + else: + entry["dot"].setStyleSheet("color: #71717a; font-size: 11px;") + + def _do_switch_to(self, idx: int): + if idx == self.agent.llm_no: + return + self.agent.next_llm(n=idx) + name = self._model_name() + self._model_badge.setText(name) + self._model_info.setText(f"当前模型:{name} (#{self.agent.llm_no})") + self._add_system_notice(f"已切换至 {name},对话上下文已保留") + self._refresh_model_rows_style() + + def _start_health_checks(self): + self._health_results.clear() + self._health_pending = 0 + self._health_result_queue = _queue.Queue() + for entry in self._model_row_widgets: + entry["dot"].setStyleSheet("color: #71717a; font-size: 11px;") + entry["dot"].setText("◌") + for idx, tc in enumerate(self.agent.llmclients): + self._health_pending += 1 + t = threading.Thread(target=self._check_backend, args=(idx, tc.backend), daemon=True) + t.start() + if not hasattr(self, '_health_poll_timer'): + self._health_poll_timer = QTimer(self) + self._health_poll_timer.timeout.connect(self._poll_health_results) + self._health_poll_timer.start(500) + + def _poll_health_results(self): + while True: + try: + idx, ok = self._health_result_queue.get_nowait() + self._health_results[idx] = ok + except _queue.Empty: + break + self._refresh_model_rows_style() + if len(self._health_results) >= self._health_pending: + self._health_poll_timer.stop() + + def _check_backend(self, idx: int, backend): + ok = False + try: + reply = backend.ask("你好") + # 兼容生成器函数(NativeClaudeSession.ask是生成器) + if hasattr(reply, '__iter__') and not isinstance(reply, str): + reply = ''.join(str(b) for b in reply if isinstance(b, str)) + text = str(reply).strip() if reply else "" + ok = len(text) > 0 and not text.startswith("Error") and not text.startswith("[") + print(f"[HealthCheck] Backend #{idx} {type(backend).__name__}/{backend.model}: {'OK' if ok else 'FAIL'} -> {text[:60]}") + except Exception as e: + print(f"[HealthCheck] Backend #{idx} {type(backend).__name__}/{backend.model}: ERROR -> {e}") + ok = False + if hasattr(backend, 'raw_msgs') and backend.raw_msgs: + backend.raw_msgs = [m for m in backend.raw_msgs if m.get("prompt") != "你好"] + self._health_result_queue.put((idx, ok)) + + # ── event filter (Enter key in text edit, Escape to close search) ────────── + def eventFilter(self, obj, event): + if event.type() == QEvent.KeyPress: + if obj is self._search_input and event.key() == Qt.Key_Escape: + self._hide_search() + return True + if obj is self._input and event.key() in (Qt.Key_Return, Qt.Key_Enter): + if not (event.modifiers() & Qt.ShiftModifier): + self._handle_send() + return True + # 搜索框失焦时关闭搜索 + if event.type() == QEvent.FocusOut and obj is self._search_input: + # 延迟关闭,等待点击事件处理完毕 + QTimer.singleShot(50, self._hide_search_if_no_focus) + return super().eventFilter(obj, event) + + def _on_text_changed(self): + n = len(self._input.toPlainText()) + self._char_lbl.setText(f"{n} / 2000") + + # ── file attachment ──────────────────────────────────────────────────────── + def _attach_files(self): + paths, _ = QFileDialog.getOpenFileNames( + self, "选择附件", "", + "All Files (*);;" + "Images (*.png *.jpg *.jpeg *.gif *.webp *.bmp);;" + "Text (*.txt *.md *.py *.json *.csv *.yaml *.yml *.log *.js *.ts *.sql)", + ) + for path in paths: + name = os.path.basename(path) + if any(f["name"] == name for f in self._pending_files): + continue + ext = os.path.splitext(path)[1].lower() + img_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"} + mime = (f"image/{ext[1:]}" if ext in img_exts else + "text/plain" if ext in TEXT_FILE_EXTS else + "application/octet-stream") + try: + with open(path, "rb") as fh: + raw = fh.read() + if len(raw) > MAX_UPLOAD_BYTES: + print(f"[Attach] 文件过大,已跳过: {name} ({len(raw)} bytes)") + continue + self._pending_files.append({"name": name, "type": mime, "raw": raw}) + except Exception as e: + print(f"[Attach] Failed to read {path}: {e}") + self._refresh_chips() + + def _refresh_chips(self): + while self._chips_ly.count(): + item = self._chips_ly.takeAt(0) + if item.widget(): + item.widget().deleteLater() + if not self._pending_files: + self._chips_row.hide() + return + for f in self._pending_files: + chip = QLabel(f['name']) + chip.setStyleSheet(f""" + QLabel {{ background: rgba(55,55,65,0.7); color: {C['text']}; + border: 1px solid {C['border'].name()}; border-radius: 6px; + padding: 3px 8px; font-size: 11px; }} + """) + self._chips_ly.addWidget(chip) + self._chips_ly.addStretch() + self._chips_row.show() + + # ── send / streaming ─────────────────────────────────────────────────────── + _SEND_BTN_STYLE = """ + QPushButton { background: #e4e4e7; border: none; border-radius: 17px; } + QPushButton:hover { background: #f4f4f5; } + QPushButton:pressed { background: #d4d4d8; } + """ + _STOP_BTN_STYLE = """ + QPushButton { background: rgba(239,68,68,0.85); border: none; border-radius: 17px; } + QPushButton:hover { background: rgba(248,113,113,0.9); } + QPushButton:pressed { background: rgba(220,38,38,0.9); } + """ + + def _set_send_mode(self): + self._is_streaming = False + self._send_btn.setText("") + self._send_btn.setIcon(_svg_icon("send_arrow", _SVG_SEND, "#18181b")) + self._send_btn.setIconSize(QSize(18, 18)) + self._send_btn.setStyleSheet(self._SEND_BTN_STYLE) + + def _set_stop_mode(self): + self._is_streaming = True + self._send_btn.setText("") + self._send_btn.setIcon(_svg_icon("stop_circle", _SVG_STOP, "#ffffff")) + self._send_btn.setIconSize(QSize(16, 16)) + self._send_btn.setStyleSheet(self._STOP_BTN_STYLE) + + def _on_send_btn_click(self): + if self._is_streaming: + self._do_stop() + else: + self._handle_send() + + def _handle_send(self): + text = self._input.toPlainText().strip() + files = self._pending_files.copy() + if not text and not files: + return + + if text.startswith("/"): + self._input.clear() + self._pending_files.clear() + self._refresh_chips() + self._handle_command(text) + return + + prompt = text or "请分析我上传的附件。" + full_prompt, display_prompt, _ = _build_prompt_with_uploads(prompt, files) + + # Clear input state + self._input.clear() + self._pending_files.clear() + self._refresh_chips() + + # Update session title + if self._session["title"] == "新对话" and prompt: + self._session["title"] = prompt[:20] + ("..." if len(prompt) > 20 else "") + + from datetime import datetime + now_iso = datetime.now().isoformat() + user_idx = len(self._messages) + self._messages.append({"role": "user", "content": display_prompt, "created_at": now_iso}) + self._add_msg_row( + "user", + display_prompt, + created_at=now_iso, + on_delete=lambda idx=user_idx: self._delete_message(idx), + on_rewrite=lambda idx=user_idx: self._rewrite_message(idx) + ) + self._update_token_usage() + + # Start streaming — reset scroll lock so new output auto-scrolls + self._user_scrolled_up = False + self._streaming_text = "" + # The streaming row will be replaced when done, it doesn't need deletion/export + self._streaming_row = self._add_msg_row("assistant", "▌") + self._streaming_row.set_finished(False) + self._set_stop_mode() + self._streaming_badge.show() + + self._display_queue = self.agent.put_task(f"{FILE_HINT}\n\n{full_prompt}", source="user") + self._poll_timer.start(40) + + def _handle_command(self, cmd: str): + parts = cmd.split() + op = parts[0].lower() if parts else "" + if op == "/help": + self._add_system_notice(HELP_TEXT) + elif op == "/stop": + self._do_stop() + self._add_system_notice("⏹️ 已停止") + elif op == "/status": + llm = self._model_name() + state = "🔴 运行中" if self.agent.is_running else "🟢 空闲" + self._add_system_notice(f"状态: {state}\nLLM: [{self.agent.llm_no}] {llm}") + elif op == "/llm": + if not self.agent.llmclient: + self._add_system_notice("❌ 当前没有可用的 LLM 配置") + elif len(parts) > 1: + try: + idx = int(parts[1]) + self._do_switch_to(idx) + except Exception: + self._add_system_notice(f"用法: /llm <0-{len(self.agent.llmclients) - 1}>") + else: + lines = [f"{'→' if i == self.agent.llm_no else ' '} [{i}] {getattr(c, 'name', type(c.backend).__name__ + '/' + c.backend.model)}" + for i, c in enumerate(self.agent.llmclients)] + self._add_system_notice("LLMs:\n" + "\n".join(lines)) + elif op == "/restore": + restored_info, err = format_restore() + if err: + self._add_system_notice(err) + else: + restored, fname, count = restored_info + self.agent.abort() + self.agent.history.extend(restored) + self._add_system_notice(f"✅ 已恢复 {count} 轮对话\n来源: {fname}") + elif op == "/new": + self._do_clear() + self._add_system_notice("✅ 已开启新对话") + else: + self._add_system_notice(f"未知命令: {cmd}\n{HELP_TEXT}") + + def _poll_queue(self): + if not self._display_queue: + return + try: + while True: + item = self._display_queue.get_nowait() + if not isinstance(item, dict) or ("next" not in item and "done" not in item): + print(f"[Queue] 跳过异常项: {item}") + continue + if "next" in item: + self._streaming_text = item["next"] + if self._streaming_row: + self._streaming_row.set_text(self._streaming_text + " ▌") + self._update_token_usage() + self._scroll_bottom() + if "done" in item: + final = item["done"] + from datetime import datetime + now_iso = datetime.now().isoformat() + # Remove the temporary streaming row + if self._streaming_row: + # Find its position in the layout to replace it + idx = self._msg_layout.indexOf(self._streaming_row) + self._streaming_row.deleteLater() + self._streaming_row = None + # Add the final message with proper buttons + assist_idx = len(self._messages) + self._messages.append({"role": "assistant", "content": final, "created_at": now_iso}) + # Insert at the same position where the streaming row was, or before the stretch + insert_pos = idx if idx >= 0 else self._msg_layout.count() - 1 + row = _MsgRow( + final, + "assistant", + on_resend=self._regenerate_response, + on_delete=lambda idx=assist_idx: self._delete_message(idx), + on_rewrite=None, + created_at=now_iso + ) + # 自动展开最后一个 LLM Running 块,方便用户直接看到结果 + for _, title, _ in reversed(row._parse_foldable_blocks(final)): + if title is not None and title in row._folded_ids and 'LLM Running' in title: + row._folded_ids.remove(title) + row._label.setHtml(row._render_with_folds(final)) + row._adjust_browser_height() + break + self._msg_layout.insertWidget(insert_pos, row) + self._poll_timer.stop() + self._set_send_mode() + self._streaming_badge.hide() + self.last_reply_time = time.time() + self._update_token_usage() + self._scroll_bottom() + self._auto_save() + break + except _queue.Empty: + pass + + def _add_msg_row(self, role: str, text: str, created_at: str = None, on_delete=None, on_rewrite=None) -> _MsgRow: + row = _MsgRow( + text, + role, + on_resend=self._regenerate_response if role != "user" else None, + on_delete=on_delete, + on_rewrite=on_rewrite, + created_at=created_at + ) + self._msg_layout.insertWidget(self._msg_layout.count() - 1, row) + self._scroll_bottom() + return row + + def _regenerate_response(self): + """Resend the last user message to regenerate the assistant response.""" + if self._is_streaming: + return + for msg in reversed(self._messages): + if msg["role"] == "user": + self._input.setPlainText(msg["content"]) + self._handle_send() + break + + def _delete_message(self, index: int): + """Delete the message at the given index.""" + if index < 0 or index >= len(self._messages): + return + # Remove from data + self._messages.pop(index) + # Rebuild all rows to ensure on_delete indices are correct + self._rebuild_messages() + # Update + self._update_token_usage() + self._auto_save() + + def _rewrite_message(self, index: int): + """Rewrite the user message at the given index.""" + if index < 0 or index >= len(self._messages): + return + if self._messages[index]["role"] != "user": + return + # Get the content and fill it into the input + content = self._messages[index]["content"] + self._input.setPlainText(content) + # Remove this message and everything after it + self._messages = self._messages[:index] + # Rebuild UI + self._rebuild_messages() + self._update_token_usage() + self._auto_save() + + def _on_scroll(self, value): + sb = self._scroll.verticalScrollBar() + self._user_scrolled_up = value < sb.maximum() - 30 + self._update_nav_visibility() + + def _update_nav_visibility(self): + sb = self._scroll.verticalScrollBar() + max_val = sb.maximum() + vp_h = self._scroll.viewport().height() + total_h = max_val + vp_h + show_nav = max_val > 0 and total_h >= vp_h * 1.5 + + if show_nav: + self._nav_widget.show() + self._nav_up.setEnabled(sb.value() > 2) + self._nav_down.setEnabled(max_val > 0 and sb.value() < max_val - 2) + else: + self._nav_widget.hide() + + def _scroll_to_top(self): + self._user_scrolled_up = True + self._scroll.verticalScrollBar().setValue(0) + + def _scroll_to_bottom(self): + self._user_scrolled_up = False + QTimer.singleShot(60, lambda: self._scroll.verticalScrollBar().setValue( + self._scroll.verticalScrollBar().maximum() + )) + + def _scroll_bottom(self): + if self._user_scrolled_up: + return + QTimer.singleShot(60, lambda: self._scroll.verticalScrollBar().setValue( + self._scroll.verticalScrollBar().maximum() + )) + + # ── inject (autonomous mode) ─────────────────────────────────────────────── + def inject_message(self, text: str): + """Programmatically send a message (called by idle monitor).""" + self._input.setPlainText(text) + self._handle_send() + + # ── history ──────────────────────────────────────────────────────────────── + def _refresh_history(self): + self._history = _load_history() + self._hist_list.clear() + for s in reversed(self._history[-20:]): + n = len(s.get("messages", [])) + item = QListWidgetItem(f" {s.get('title','未命名')} ({n} 条)") + item.setData(Qt.UserRole, s) + self._hist_list.addItem(item) + + def _restore_selected(self, item=None): + item = item or self._hist_list.currentItem() + if not item: + return + s = item.data(Qt.UserRole) + if s: + self._session = s.copy() + self._messages = s.get("messages", []).copy() + self._rebuild_messages() + self._switch_tab(0) + self._update_token_usage() + search_text = self._search_input.text().strip() + if search_text: + QTimer.singleShot(50, lambda: self._search_current_chat(search_text)) + + def _delete_selected(self): + item = self._hist_list.currentItem() + if not item: + return + s = item.data(Qt.UserRole) + if s: + self._history = [h for h in self._history if h.get("id") != s.get("id")] + _save_history(self._history) + self._refresh_history() + + def _rebuild_messages(self): + while self._msg_layout.count() > 1: + it = self._msg_layout.takeAt(0) + if it.widget(): + it.widget().deleteLater() + for i, m in enumerate(self._messages): + rewrite_cb = (lambda idx=i: self._rewrite_message(idx)) if m["role"] == "user" else None + self._add_msg_row( + m["role"], + m["content"], + created_at=m.get("created_at"), + on_delete=lambda idx=i: self._delete_message(idx), + on_rewrite=rewrite_cb + ) + self._update_token_usage() + + def _update_token_usage(self): + in_chars = sum(len(m.get("content", "")) for m in self._messages if m.get("role") == "user") + out_chars = sum(len(m.get("content", "")) for m in self._messages if m.get("role") == "assistant") + if getattr(self, "_is_streaming", False) and getattr(self, "_streaming_text", ""): + out_chars += len(self._streaming_text) + + in_tokens = int(in_chars / 2.5) + out_tokens = int(out_chars / 2.5) + + if in_tokens == 0 and out_tokens == 0: + self._token_lbl.setText("") + else: + self._token_lbl.setText(f"| 会话上下文消耗: 入 {in_tokens} 出 {out_tokens} tokens") + + # ── SOP ──────────────────────────────────────────────────────────────────── + def _refresh_sop(self): + self._sop_list.clear() + file_icon = _svg_icon("sop_file_item", _SVG_FILE, C["muted"]) + for path in sorted(glob.glob(os.path.join(os.path.dirname(os.path.dirname(__file__)), "memory", "*.md"))): + name = os.path.basename(path) + size = os.path.getsize(path) + it = QListWidgetItem(name) + it.setIcon(file_icon) + it.setData(Qt.UserRole, path) + it.setToolTip(f"{size:,} 字节") + self._sop_list.addItem(it) + + def _load_sop(self, item): + if not item: + return + path = item.data(Qt.UserRole) + try: + with open(path, "r", encoding="utf-8") as f: + self._sop_viewer.setHtml(_md_to_html(f.read())) + except Exception as e: + self._sop_viewer.setPlainText(f"读取失败: {e}") + + # ── settings actions ─────────────────────────────────────────────────────── + def _model_name(self) -> str: + if self.agent.llmclient is None: + return "未配置" + try: + return self.agent.get_llm_name() + except Exception: + return "未知" + + def _add_system_notice(self, text: str): + """Insert a small centered notice label (not tracked as a message).""" + lbl = QLabel(text) + lbl.setWordWrap(True) + lbl.setAlignment(Qt.AlignCenter) + lbl.setStyleSheet( + "QLabel { background: transparent; color: #71717a;" + " border: none; padding: 6px 20px; font-size: 12px; }" + ) + self._msg_layout.insertWidget(self._msg_layout.count() - 1, lbl) + self._scroll_bottom() + + def _do_stop(self): + self.agent.abort() + self._poll_timer.stop() + self._set_send_mode() + self._streaming_badge.hide() + if self._streaming_row: + self._streaming_row.set_text(self._streaming_text or "(已停止)") + self._streaming_row.set_finished(True) + self._streaming_row = None + self._update_token_usage() + + def _do_reset_prompt(self): + if self.agent.llmclient and hasattr(self.agent.llmclient, 'last_tools'): + self.agent.llmclient.last_tools = "" + + def _auto_save(self): + if not self._messages: + return + if self._session.get("title") == "新对话": + first_user = next( + (m["content"] for m in self._messages if m["role"] == "user"), "" + ) + if first_user: + self._session["title"] = first_user[:30].replace("\n", " ") + self._do_save() + + def _do_save(self): + if not self._messages: + return + self._session["messages"] = self._messages.copy() + self._session["updatedAt"] = datetime.now().isoformat() + self._history = _load_history() + for i, s in enumerate(self._history): + if s.get("id") == self._session["id"]: + self._history[i] = self._session.copy() + break + else: + self._history.append(self._session.copy()) + _save_history(self._history) + + def _do_clear(self): + self._messages.clear() + self._session = {"id": _make_session_id(), "title": "新对话", "messages": []} + self._rebuild_messages() + self._switch_tab(0) + self._update_token_usage() + + def _new_session(self): + if self._messages: + self._do_save() + self._do_clear() + + def _do_toggle_auto(self): + self.autonomous_enabled = not self.autonomous_enabled + self._auto_btn.setChecked(self.autonomous_enabled) + lbl = "暂停自主行动" if self.autonomous_enabled else "开启自主行动 (idle > 30 min 自动触发)" + self._auto_btn.setText(lbl) + + def _do_trigger_auto(self): + self.inject_message( + "[AUTO]🤖 用户触发了自主行动,请阅读自动化sop,选择并执行一项有价值的任务。" + ) + + # ── helpers ──────────────────────────────────────────────────────────────── + @staticmethod + def _small_btn_style(color: str) -> str: + return ( + f"QPushButton {{ background: {color}; color: white; border: none;" + f" border-radius: 7px; padding: 4px 12px; font-size: 12px; font-weight: 600; }}" + f"QPushButton:hover {{ opacity: 0.85; }}" + ) + + +# ══════════════════════════════════════════════════════════════════════ +# Entry Point +# ══════════════════════════════════════════════════════════════════════ + +def main(): + # High-DPI support + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + app.setApplicationName("GenericAgent") + + # Font + font = QFont() + # Keep English glyphs in Arial; Chinese falls back to Microsoft YaHei. + try: + font.setFamilies(["Arial", "Microsoft YaHei"]) + except Exception: + font.setFamily("Microsoft YaHei") + font.setPointSize(10) + app.setFont(font) + + # ── Agent initialisation ────────────────────────────── + agent = GeneraticAgent() + if agent.llmclient is None: + QMessageBox.critical( + None, + "未配置 LLM", + "未在 mykey.py 中发现任何可用的 LLM 接口配置,\n程序将在无 LLM 模式下运行。", + ) + else: + threading.Thread(target=agent.run, daemon=True).start() + + # ── Windows ─────────────────────────────────────────── + panel = ChatPanel(agent) + button = FloatingButton(panel) + button.show() + + # Position panel next to button and show it on first launch + button._position_panel() + panel.show() + + scr = QApplication.primaryScreen().availableGeometry() + print(f"[GenericAgent] 启动成功") + print(f" 屏幕分辨率: {scr.width()}x{scr.height()}") + print(f" 悬浮按钮: ({button.x()}, {button.y()})") + print(f" 聊天面板: ({panel.x()}, {panel.y()})") + print(f" 关闭面板后可点击右下角发光按钮重新打开") + + # ── Idle monitor (autonomous mode) ──────────────────── + _last_trigger = 0.0 + + def idle_check(): + nonlocal _last_trigger + if not panel.autonomous_enabled: + return + now = time.time() + if now - _last_trigger < AUTO_COOLDOWN: + return + idle = now - panel.last_reply_time + if idle > AUTO_IDLE_THRESHOLD: + _last_trigger = now + panel.inject_message( + "[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。" + ) + + idle_timer = QTimer() + idle_timer.timeout.connect(idle_check) + idle_timer.start(5000) # check every 5 s + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/frontends/review_cmd.py b/frontends/review_cmd.py new file mode 100644 index 0000000..b0b9825 --- /dev/null +++ b/frontends/review_cmd.py @@ -0,0 +1,81 @@ +"""`/review` 命令:in-session adversarial code reviewer。 + +用户输入整段作为 user_request 注入 inline prompt;主 agent 在当前 session 内按 prompt +协议自取审阅范围(用户点名的文件 / git diff)并 echo 报告,不开 subagent、不写落盘文件。 + +prompt 与 SOP 仅来自 `memory/review_sop/`,作为独立公共入口,不读取其他工作流的私有 prompt。 +""" +from __future__ import annotations +import os +from typing import Optional + +CODE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_PROMPT_DIR = 'review_sop' +_INLINE_PROMPT_ZH = 'review_inline_prompt.txt' +_INLINE_PROMPT_EN = 'review_inline_prompt.en.txt' +_STUB_FALLBACK = ( + '[/review in-session] (⚠️ prompt 文件缺失: {fpath} → {err})\n\n' + '# 本轮用户请求\n{user_request}\n\n' + '请按 memory/code_review_principles.md 评审,直接 echo 报告到对话。\n' + '不要写 review.md,不要打 [ROUND END]。' +) + +def _render_prompt(user_request: str) -> str: + """加载 /review inline prompt 并注入 user_request + ga_root。""" + lang = os.environ.get('GA_LANG', '').strip().lower() + fname = _INLINE_PROMPT_EN if lang == 'en' else _INLINE_PROMPT_ZH + fpath = os.path.join(CODE_ROOT, 'memory', _PROMPT_DIR, fname) + ga_root = CODE_ROOT.replace('\\', '/') + try: + with open(fpath, 'r', encoding='utf-8') as f: + return f.read().format(user_request=user_request, ga_root=ga_root) + except Exception as e: + return _STUB_FALLBACK.format(fpath=fpath, err=e, user_request=user_request) + +def _help_text() -> str: + return ( + '**/review 用法**: in-session adversarial code reviewer\n\n' + '`/review ` # 默认审本次 uncommitted 改动(主 agent 跑 git diff)\n' + '`/review <自然语言请求> ` # 主 agent 按你描述的范围去审\n\n' + '例:\n' + ' `/review`\n' + ' `/review 我刚改了 review_cmd.py 和 tuiapp_v2.py,关注 prompt 注入`\n' + ' `/review 审 frontends 目录下所有改过的文件`\n\n' + '产出:直接对话 markdown(不写文件、不开 subagent)。\n' + '协议: `memory/review_sop/review_inline_prompt.txt` + `memory/code_review_principles.md`' + ) + +_DEFAULT_REQUEST_ZH = '(无具体请求 — 默认审本次 uncommitted 改动:用 code_run 跑 `git diff --stat HEAD` 与 `git diff HEAD`)' +_DEFAULT_REQUEST_EN = '(no specific request — default to uncommitted diff: run `git diff --stat HEAD` and `git diff HEAD`)' +_HEADER_ZH = '> 🔍 /review (in-session) → 主 agent 当场审,直接 echo 报告\n\n' +_HEADER_EN = '> 🔍 /review (in-session) → main agent reviews here, echoes the report inline\n\n' + +def handle(agent, body: str, display_queue) -> Optional[str]: + """body 是已剥离 `/review` 前缀的纯参数文本(由 install 剥离)。 + help → 推 done;否则注入 user_request 到 inline prompt return 给主 agent。 + 不发任何 'done' message(否则前端 `if 'done': break + finally:agent.abort` 会干掉主 agent)。 + """ + if body in ('help', '?', '-h', '--help'): + display_queue.put({'done': _help_text(), 'source': 'system'}) + return None + en = os.environ.get('GA_LANG', '').strip().lower() == 'en' + user_request = body or (_DEFAULT_REQUEST_EN if en else _DEFAULT_REQUEST_ZH) + header = _HEADER_EN if en else _HEADER_ZH + return header + _render_prompt(user_request) + +def install(cls): + """`/review` 一律接管,前缀剥离在此完成,handle 只接 body(职责单一)。""" + orig = cls._handle_slash_cmd + if getattr(orig, '_review_patched', False): return + def patched(self, raw_query, display_queue): + s = (raw_query or '').strip() + if s == '/review': + body = '' + elif s.startswith('/review ') or s.startswith('/review\t'): + body = s[len('/review'):].strip() + else: + return orig(self, raw_query, display_queue) + r = handle(self, body, display_queue) + return None if r is None else r + patched._review_patched = True + cls._handle_slash_cmd = patched diff --git a/frontends/session_names.py b/frontends/session_names.py new file mode 100644 index 0000000..adc1734 --- /dev/null +++ b/frontends/session_names.py @@ -0,0 +1,105 @@ +"""Persistent display names for `/continue`-able sessions. + +JSON sidecar at `temp/model_responses/session_names.json` maps log-file +basename → user name. Touched only by `/rename` and `/continue `. +""" +import glob, json, os, re, threading + +_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'temp', 'model_responses') +_REG_PATH = os.path.join(_LOG_DIR, 'session_names.json') +_LOG_RE = re.compile(r'^model_responses_(\d+)\.txt$') +_lock = threading.Lock() + + +def _load() -> dict: + try: + with open(_REG_PATH, encoding='utf-8') as f: + d = json.load(f) + return d if isinstance(d, dict) else {} + except Exception: + return {} + + +def _save(d: dict) -> None: + os.makedirs(_LOG_DIR, exist_ok=True) + tmp = _REG_PATH + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(d, f, ensure_ascii=False, indent=2) + os.replace(tmp, _REG_PATH) + + +def _resolve_basename(basename: str): + # Registered file may be cleared by `continue_cmd._snapshot_current_log` + # on /new or /continue; fall back to the newest non-empty snapshot of the + # same PID so a mid-session rename survives the rotation. + p = os.path.join(_LOG_DIR, basename) + if os.path.isfile(p) and os.path.getsize(p) > 0: + return p + m = _LOG_RE.match(basename) + if m: + snaps = glob.glob(os.path.join(_LOG_DIR, f'model_responses_snapshot_{m.group(1)}_*.txt')) + snaps.sort(key=os.path.getmtime, reverse=True) + for s in snaps: + if os.path.getsize(s) > 0: + return s + return None + + +def set_name(log_path: str, name: str) -> None: + """Persist `name` for `log_path`. Empty name removes the entry.""" + key = os.path.basename(log_path) + with _lock: + d = _load() + if name: d[key] = name + else: d.pop(key, None) + _save(d) + + +def migrate(old_path: str, new_path: str) -> None: + """Move the entry from old basename to new basename after /continue.""" + if old_path == new_path: return + old_key, new_key = os.path.basename(old_path), os.path.basename(new_path) + with _lock: + d = _load() + if old_key in d: + d[new_key] = d.pop(old_key) + _save(d) + + +def name_for(log_path: str) -> str: + return _load().get(os.path.basename(log_path), '') + + +def has_name(name: str, exclude_basename: str = None) -> bool: + """True when any other entry already owns `name` (case-insensitive).""" + target = (name or '').strip().lower() + if not target: return False + return any(v.lower() == target for k, v in _load().items() if k != exclude_basename) + + +def gc() -> int: + """Drop entries whose log file is gone or empty. Returns count removed.""" + with _lock: + d = _load() + bad = [k for k in d if _resolve_basename(k) is None] + for k in bad: d.pop(k) + if bad: _save(d) + return len(bad) + + +def path_for(name: str, exclude_basename: str = None): + """Resolve `name` → newest resolvable log path. Exact-match then unique-prefix.""" + target = (name or '').strip().lower() + if not target: return None + d = _load() + matches = [(k, v) for k, v in d.items() if v.lower() == target] + if not matches: + matches = [(k, v) for k, v in d.items() if v.lower().startswith(target)] + if len(matches) > 1: matches = [] + if exclude_basename is not None: + matches = [m for m in matches if m[0] != exclude_basename] + resolved = [(p, k) for p, k in ((_resolve_basename(k), k) for k, _ in matches) if p] + if not resolved: return None + resolved.sort(key=lambda pk: os.path.getmtime(pk[0]), reverse=True) + return resolved[0][0] diff --git a/frontends/skins/boy/pet.png b/frontends/skins/boy/pet.png new file mode 100644 index 0000000..506622d Binary files /dev/null and b/frontends/skins/boy/pet.png differ diff --git a/frontends/skins/boy/skin.json b/frontends/skins/boy/skin.json new file mode 100644 index 0000000..9a116cb --- /dev/null +++ b/frontends/skins/boy/skin.json @@ -0,0 +1,63 @@ +{ + "name": "Boy", + "version": "1.0.0", + "author": "pzuh", + "source": "https://pzuh.itch.io/temple-run-game-sprites", + "description": "Boy 角色皮肤", + "style": "pixel", + "format": "sprite", + "size": { + "width": 80, + "height": 122 + }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 64, + "frameHeight": 98, + "frameCount": 10, + "columns": 40, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 64, + "frameHeight": 98, + "frameCount": 10, + "columns": 40, + "fps": 3, + "startFrame": 20 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 64, + "frameHeight": 98, + "frameCount": 10, + "columns": 40, + "fps": 10, + "startFrame": 20 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 64, + "frameHeight": 98, + "frameCount": 10, + "columns": 40, + "fps": 24, + "startFrame": 20 + } + } + } +} diff --git a/frontends/skins/boy/skin.png b/frontends/skins/boy/skin.png new file mode 100644 index 0000000..02413f3 Binary files /dev/null and b/frontends/skins/boy/skin.png differ diff --git a/frontends/skins/dinosaur/pet.png b/frontends/skins/dinosaur/pet.png new file mode 100644 index 0000000..ee2f5b0 Binary files /dev/null and b/frontends/skins/dinosaur/pet.png differ diff --git a/frontends/skins/dinosaur/skin.json b/frontends/skins/dinosaur/skin.json new file mode 100644 index 0000000..c961b0e --- /dev/null +++ b/frontends/skins/dinosaur/skin.json @@ -0,0 +1,60 @@ +{ + "name": "Dinosaur", + "version": "1.0.0", + "author": "voidcord54", + "source": "https://voidcord54.itch.io/", + "description": "像素风小恐龙 Dinosaur", + "style": "pixel", + "format": "sprite", + "size": { "width": 128, "height": 128 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 128, + "frameHeight": 128, + "frameCount": 2, + "columns": 5, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 128, + "frameHeight": 128, + "frameCount": 2, + "columns": 5, + "fps": 4, + "startFrame": 2 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 128, + "frameHeight": 128, + "frameCount": 2, + "columns": 5, + "fps": 8, + "startFrame": 2 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 128, + "frameHeight": 128, + "frameCount": 2, + "columns": 5, + "fps": 16, + "startFrame": 2 + } + } + } +} diff --git a/frontends/skins/dinosaur/skin.png b/frontends/skins/dinosaur/skin.png new file mode 100644 index 0000000..60a596a Binary files /dev/null and b/frontends/skins/dinosaur/skin.png differ diff --git a/frontends/skins/doux/pet.png b/frontends/skins/doux/pet.png new file mode 100644 index 0000000..b4b17cc Binary files /dev/null and b/frontends/skins/doux/pet.png differ diff --git a/frontends/skins/doux/skin.json b/frontends/skins/doux/skin.json new file mode 100644 index 0000000..f084745 --- /dev/null +++ b/frontends/skins/doux/skin.json @@ -0,0 +1,61 @@ +{ + "name": "Doux", + "version": "1.0.0", + "author": "arks", + "source": "https://arks.itch.io/dino-characters", + "license": "CC0", + "description": "像素风小恐龙 Doux", + "style": "pixel", + "format": "sprite", + "size": { "width": 128, "height": 128 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 4, + "columns": 24, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 6, + "startFrame": 5 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 8, + "columns": 24, + "fps": 16, + "startFrame": 6 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 16, + "startFrame": 17 + } + } + } +} diff --git a/frontends/skins/doux/skin.png b/frontends/skins/doux/skin.png new file mode 100644 index 0000000..8fb8734 Binary files /dev/null and b/frontends/skins/doux/skin.png differ diff --git a/frontends/skins/glube/idle.png b/frontends/skins/glube/idle.png new file mode 100644 index 0000000..b2905cb Binary files /dev/null and b/frontends/skins/glube/idle.png differ diff --git a/frontends/skins/glube/pet.png b/frontends/skins/glube/pet.png new file mode 100644 index 0000000..28fe801 Binary files /dev/null and b/frontends/skins/glube/pet.png differ diff --git a/frontends/skins/glube/run.png b/frontends/skins/glube/run.png new file mode 100644 index 0000000..636ab8d Binary files /dev/null and b/frontends/skins/glube/run.png differ diff --git a/frontends/skins/glube/skin.json b/frontends/skins/glube/skin.json new file mode 100644 index 0000000..2815e26 --- /dev/null +++ b/frontends/skins/glube/skin.json @@ -0,0 +1,60 @@ +{ + "name": "Glube", + "version": "1.0.0", + "author": "SketchesWithKevin", + "source": "https://sketcheswithkevin.itch.io/glube-platformer", + "description": "像素风小怪兽 Glube", + "style": "pixel", + "format": "sprite", + "size": { "width": 65, "height": 38 }, + "animations": { + "idle": { + "file": "idle.png", + "loop": true, + "sprite": { + "frameWidth": 44, + "frameHeight": 31, + "frameCount": 6, + "columns": 6, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "walk.png", + "loop": true, + "sprite": { + "frameWidth": 65, + "frameHeight": 32, + "frameCount": 8, + "columns": 8, + "fps": 6, + "startFrame": 0 + } + }, + "run": { + "file": "run.png", + "loop": true, + "sprite": { + "frameWidth": 65, + "frameHeight": 32, + "frameCount": 8, + "columns": 8, + "fps": 12, + "startFrame": 0 + } + }, + "sprint": { + "file": "run.png", + "loop": true, + "sprite": { + "frameWidth": 65, + "frameHeight": 32, + "frameCount": 8, + "columns": 8, + "fps": 24, + "startFrame": 0 + } + } + } +} diff --git a/frontends/skins/glube/walk.png b/frontends/skins/glube/walk.png new file mode 100644 index 0000000..636ab8d Binary files /dev/null and b/frontends/skins/glube/walk.png differ diff --git a/frontends/skins/line/License.txt b/frontends/skins/line/License.txt new file mode 100644 index 0000000..e0ec332 --- /dev/null +++ b/frontends/skins/line/License.txt @@ -0,0 +1,6 @@ +License is CC0 - https://creativecommons.org/public-domain/cc0/ + +YOU CAN: + +-> You can do whatever you want with this asset, including modifying it for commercial use. +-> Credit is not required, but is greatly appreciated! \ No newline at end of file diff --git a/frontends/skins/line/pet.png b/frontends/skins/line/pet.png new file mode 100644 index 0000000..4421167 Binary files /dev/null and b/frontends/skins/line/pet.png differ diff --git a/frontends/skins/line/skin.json b/frontends/skins/line/skin.json new file mode 100644 index 0000000..09d1fdc --- /dev/null +++ b/frontends/skins/line/skin.json @@ -0,0 +1,60 @@ +{ + "name": "Line", + "version": "1.0.0", + "author": "itch.io", + "source": "https://itch.io", + "description": "Line 角色皮肤", + "style": "pixel", + "format": "sprite", + "size": { "width": 128, "height": 128 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 156, + "frameHeight": 185, + "frameCount": 4, + "columns": 28, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 156, + "frameHeight": 185, + "frameCount": 8, + "columns": 28, + "fps": 6, + "startFrame": 4 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 156, + "frameHeight": 185, + "frameCount": 8, + "columns": 28, + "fps": 10, + "startFrame": 12 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 156, + "frameHeight": 185, + "frameCount": 8, + "columns": 28, + "fps": 24, + "startFrame": 12 + } + } + } +} diff --git a/frontends/skins/line/skin.png b/frontends/skins/line/skin.png new file mode 100644 index 0000000..41b102e Binary files /dev/null and b/frontends/skins/line/skin.png differ diff --git a/frontends/skins/mort/pet.png b/frontends/skins/mort/pet.png new file mode 100644 index 0000000..1b4dcbe Binary files /dev/null and b/frontends/skins/mort/pet.png differ diff --git a/frontends/skins/mort/skin.json b/frontends/skins/mort/skin.json new file mode 100644 index 0000000..f3a031c --- /dev/null +++ b/frontends/skins/mort/skin.json @@ -0,0 +1,61 @@ +{ + "name": "Mort", + "version": "1.0.0", + "author": "arks", + "source": "https://arks.itch.io/dino-characters", + "license": "CC0", + "description": "像素风小恐龙 Mort", + "style": "pixel", + "format": "sprite", + "size": { "width": 128, "height": 128 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 4, + "columns": 24, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 6, + "startFrame": 5 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 8, + "columns": 24, + "fps": 16, + "startFrame": 6 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 16, + "startFrame": 17 + } + } + } +} diff --git a/frontends/skins/mort/skin.png b/frontends/skins/mort/skin.png new file mode 100644 index 0000000..9089923 Binary files /dev/null and b/frontends/skins/mort/skin.png differ diff --git a/frontends/skins/tard/pet.png b/frontends/skins/tard/pet.png new file mode 100644 index 0000000..12c9aec Binary files /dev/null and b/frontends/skins/tard/pet.png differ diff --git a/frontends/skins/tard/skin.json b/frontends/skins/tard/skin.json new file mode 100644 index 0000000..92c6304 --- /dev/null +++ b/frontends/skins/tard/skin.json @@ -0,0 +1,61 @@ +{ + "name": "Tard", + "version": "1.0.0", + "author": "arks", + "source": "https://arks.itch.io/dino-characters", + "license": "CC0", + "description": "像素风小恐龙 Tard", + "style": "pixel", + "format": "sprite", + "size": { "width": 128, "height": 128 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 4, + "columns": 24, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 6, + "startFrame": 5 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 8, + "columns": 24, + "fps": 16, + "startFrame": 6 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 16, + "startFrame": 17 + } + } + } +} diff --git a/frontends/skins/tard/skin.png b/frontends/skins/tard/skin.png new file mode 100644 index 0000000..c99f59e Binary files /dev/null and b/frontends/skins/tard/skin.png differ diff --git a/frontends/skins/vita/pet.png b/frontends/skins/vita/pet.png new file mode 100644 index 0000000..005dff5 Binary files /dev/null and b/frontends/skins/vita/pet.png differ diff --git a/frontends/skins/vita/skin.json b/frontends/skins/vita/skin.json new file mode 100644 index 0000000..0978d20 --- /dev/null +++ b/frontends/skins/vita/skin.json @@ -0,0 +1,61 @@ +{ + "name": "Vita", + "version": "1.0.0", + "author": "arks", + "source": "https://arks.itch.io/dino-characters", + "license": "CC0", + "description": "像素风小恐龙 Vita", + "style": "pixel", + "format": "sprite", + "size": { "width": 128, "height": 128 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 4, + "columns": 24, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 6, + "startFrame": 5 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 8, + "columns": 24, + "fps": 16, + "startFrame": 6 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 16, + "startFrame": 17 + } + } + } +} diff --git a/frontends/skins/vita/skin.png b/frontends/skins/vita/skin.png new file mode 100644 index 0000000..1ee6744 Binary files /dev/null and b/frontends/skins/vita/skin.png differ diff --git a/frontends/slash_cmds.py b/frontends/slash_cmds.py new file mode 100644 index 0000000..6ceb0a8 --- /dev/null +++ b/frontends/slash_cmds.py @@ -0,0 +1,597 @@ +"""Slash-command prompt builders + scheduler-task discovery. + +Goal of this module: keep TUI files (tuiapp_v2.py / tui_v3.py) thin. They only +need to forward `/update`, `/autorun`, `/morphling`, `/goal`, `/hive` +to the corresponding `build_*_prompt(args)` here, and ask +`list_scheduler_tasks()` / `start_scheduler_task()` for the `/scheduler` picker. + +Design (per user 2026-05-27): +- All non-/scheduler commands are *prompt injection*: we craft a system-style + request and feed it to the main agent as a normal user message (the TUI is + free to display the raw `/cmd ...` as the visible bubble). This keeps the + agent in-session, lets it use every tool/SOP it normally would, and means + this file owns zero LLM logic. +- `/scheduler` is the only exception — it touches local FS state directly via + `sche_tasks/*.json` and the existing scheduler daemon, no LLM needed. +- All prompts deliberately *name* the relevant SOP file so the agent re-reads + it before acting (per CONSTITUTION rule 2: SOP-first). + +This module has zero TUI imports — both frontends can depend on it without +either depending on the other. +""" +from __future__ import annotations + +import json +import os +import shutil +import sys +import subprocess +import time +from pathlib import Path +from typing import Optional + + +_USER_SHELL: tuple[list[str], str] | None = None + +COMMIT_SIGNATURE_PROMPT = 'When you create a git commit, append "Co-Authored-By: GenericAgent " as the final line of the commit message.' + +def detect_user_shell() -> tuple[list[str], str]: + """Return `([executable, ...flags_for_-c], display_name)` for the user's + interactive shell. Cached after first call. + + `!cmd` in tui_v2 / tui_v3 invokes this so commands like `ls`, pipes, + globs, and shell builtins behave the way the user expects in whatever + shell launched the app, instead of hardcoding cmd.exe / /bin/sh. + + Resolution order: + 1. `$SHELL` if it points to an existing file (Unix, Git Bash, WSL) + 2. Windows only: Git Bash at the canonical install paths + 3. `bash` anywhere on PATH (WSL bash, Cygwin, MSYS2, etc.) + 4. Windows only: `pwsh` then `powershell.exe` on PATH + 5. Unix `/bin/sh` / Windows `%COMSPEC%` (cmd.exe) — last resort + """ + global _USER_SHELL + if _USER_SHELL is not None: + return _USER_SHELL + + s = os.environ.get("SHELL") + if s and os.path.exists(s): + name = os.path.basename(s) + if name.lower().endswith(".exe"): + name = name[:-4] + _USER_SHELL = ([s, "-c"], name) + return _USER_SHELL + + if sys.platform == "win32": + for p in ( + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files (x86)\Git\bin\bash.exe", + ): + if os.path.exists(p): + _USER_SHELL = ([p, "-c"], "bash") + return _USER_SHELL + bash = shutil.which("bash") + if bash: + _USER_SHELL = ([bash, "-c"], "bash") + return _USER_SHELL + for name in ("pwsh", "powershell"): + p = shutil.which(name) + if p: + # -NoProfile keeps each `!cmd` snappy + reproducible. + _USER_SHELL = ([p, "-NoProfile", "-Command"], name) + return _USER_SHELL + cmd = os.environ.get("COMSPEC", "cmd.exe") + _USER_SHELL = ([cmd, "/d", "/s", "/c"], "cmd") + return _USER_SHELL + + _USER_SHELL = (["/bin/sh", "-c"], "sh") + return _USER_SHELL + + + +# Repo root = parent of frontends/. Avoid hard-coding; both TUIs live next to +# this file and share the same anchor. +_ROOT = Path(__file__).resolve().parent.parent + +# Language resolution is owned here (not passed in as a formal arg) so every +# prompt builder stays single-parameter and TUI call sites don't need to know +# which prompt happens to be bilingual. Source of truth, in order: +# 1. `GA_LANG` env var (scriptable override; matches tui_v3 convention) +# 2. tui_v3's persisted settings file (same path as tui_v3.py:_SETTINGS_PATH) +# 3. system locale (zh* → 'zh', else 'en') +# When the user switches language inside tui_v3 (set_lang persists), the next +# call here picks it up automatically -- no formal coupling, just a shared file. +_SETTINGS_PATH = _ROOT / "temp" / "tui_v3_settings.json" + + +def _current_lang() -> str: + env = (os.environ.get("GA_LANG") or "").strip().lower() + if env in ("zh", "en"): + return env + try: + with open(_SETTINGS_PATH, "r", encoding="utf-8") as f: + saved = (json.load(f) or {}).get("lang") + if saved in ("zh", "en"): + return saved + except Exception: + pass + for var in ("LC_ALL", "LC_MESSAGES", "LANG"): + v = os.environ.get(var, "") + if v: + return "zh" if v.lower().startswith("zh") else "en" + return "en" + + +# ----- prompt builders (pure functions, no I/O) --------------------------- +# SOP paths are written inline as literal strings in each builder below: a +# literal is self-documenting and locally readable, and a stale path is a +# zero-radius failure (the prompt is a hint to an intelligent agent, which +# re-reads the dir / asks if a SOP moved) — so we deliberately do NOT wrap it +# in a registry + existence-check machinery. + +def _tail(args_text: str, label: str = "额外指示") -> str: + """Append user-supplied args after a slash command as a free-form suffix. + + User pattern (2026-05-27): the base prompt is a fixed injection that names + the SOP path; anything the user types after `/cmd ` is appended verbatim so + they can add per-invocation hints (e.g. `/morphling https://github.com/...` + or `/goal 调研 X,预算 50k token`). + """ + extra = (args_text or "").strip() + return f"\n\n{label}: {extra}" if extra else "" + + +def build_update_prompt(args_text: str = "") -> str: + """Prompt-only /update orchestration; actual git work stays in-agent. + + The TUI owns zero git/LLM logic. This prompt asks the normal agent loop to + do a user-friendly preflight (upstream commits + diff) before pulling. + Language follows `_current_lang()` so a /language switch in tui_v3 (or a + `GA_LANG=...` shell override) automatically flips this prompt too. + """ + if _current_lang() == "en": + return ( + "Update this GenericAgent checkout from the official upstream " + "https://github.com/Lsdefine/GenericAgent .\n" + "1. `git fetch upstream`; note the current branch and any local commits ahead.\n" + "2. Preview: upstream commits not yet local (short hash + subject + date) " + "plus a changed-files summary.\n" + "3. Align history, upstream first: with local commits ahead, `git merge upstream/main` " + "(conflicts favor upstream); otherwise `git reset --mixed upstream/main`. " + "Never create a commit.\n" + "4. Reconcile the WORKING TREE — step 3 moves only history and the index, so files " + "holding uncommitted local edits keep shadowing upstream. For each file in " + "`git diff --name-only upstream/main`, decide upstream-first:\n" + " - Stale leftovers take upstream: back the file up, then " + "`git checkout upstream/main -- `.\n" + " - Genuine local enhancements upstream lacks (local config, key templates, " + "fork-only features) stay, re-applied on top of upstream's version.\n" + " - Files with a `Fork-only local overlay` marker comment (e.g. `.gitignore`) are " + "always a MERGE: take upstream as the base, then re-append everything under the " + "marker verbatim — never drop that block.\n" + " Never `git add -A`, never blanket-checkout the branch, never blindly keep everything.\n" + "5. Verify every overlay marker still exists (grep for it); re-append any that got lost.\n" + "6. Summarize: branch HEAD, distance vs upstream, per-file outcome, backup location.\n" + "\n" + "After a successful update, say: \"Congratulations! 🎉 You have successfully updated " + "GenericAgent!\" Then you may ask: \"If you found this helpful, would you like to star " + "the GenericAgent repository? It helps the project grow! ⭐\"" + f"{_tail(args_text, 'Extra instructions')}" + ) + return ( + "请更新当前 GenericAgent 仓库,官方上游为 https://github.com/Lsdefine/GenericAgent 。\n" + "1. `git fetch upstream`;确认当前分支及是否有领先上游的本地 commit。\n" + "2. 预览:本地尚未包含的上游提交(短 hash + 标题 + 日期)及变更文件摘要。\n" + "3. 对齐提交历史,上游优先:有本地 commit 则 `git merge upstream/main`(冲突取上游);" + "否则 `git reset --mixed upstream/main`。全程不创建任何 commit。\n" + "4. 调和【工作区】——第 3 步只动历史与索引,带未提交改动的文件仍遮蔽上游最新版。对 " + "`git diff --name-only upstream/main` 列出的每个文件按上游优先裁决:\n" + " - 过时残留取上游:先备份,再 `git checkout upstream/main -- `。\n" + " - 上游没有的真本地增强(本机配置、密钥模板、fork 专属功能)保留,并在上游最新版上重新适配。\n" + " - 带 `Fork-only local overlay` 标记注释的文件(如 `.gitignore`)一律【合并】:以上游为底," + "把标记之下的内容逐字追加回去——绝不丢弃该块。\n" + " 禁止 `git add -A`、禁止整分支 checkout 覆盖、禁止盲目全保留。\n" + "5. 校验每个 overlay 标记仍存在(grep 标记);丢失即重新追加。\n" + "6. 小结:分支 HEAD、与上游差距、逐文件处理结果、备份位置。\n" + "\n" + "更新成功后,请说:\"Congratulations! 🎉 你已成功更新 GenericAgent!\"随后可邀请:" + "\"如果觉得有帮助,要不要给 GenericAgent 仓库点个 Star?这会让项目成长更快!⭐\"" + f"{_tail(args_text)}" + ) + + +def build_autorun_prompt(args_text: str = "") -> str: + return ( + "请进入「自主探索 / autonomous 模式」:先读 " + "memory/autonomous_operation_sop.md。" + "全程自驱,不可逆 / 高风险动作先 ask_user ," + "结案给一份简明回执(做了什么 / 产物在哪 / 下一步)。" + f"{_tail(args_text, '任务种子')}" + ) + + +def build_morphling_prompt(args_text: str = "") -> str: + return ( + "请启用 Morphling 模式吞噬 / 蒸馏外部项目到本仓库:先读 " + "memory/morphling_sop.md。" + "没有目标先 ask_user 取 GitHub 仓库 / 本地路径 / 能力描述。" + f"{_tail(args_text, '目标技能/仓库')}" + ) + + +def build_goal_prompt(args_text: str = "") -> str: + return ( + "请进入 Goal 模式:先读 memory/goal_mode_sop.md。" + "若未给目标,先 ask_user 一次性问清:一句话目标 + condition 约束。" + f"{_tail(args_text, '用户目标')}" + ) + + +def build_hive_prompt(args_text: str = "") -> str: + return ( + "请进入 Goal Hive 模式(多 worker 协作版 goal):先读 " + "memory/goal_hive_sop.md。" + "集群目标 / worker 配额 / 终止条件未明确时先 ask_user 补齐再启动。" + f"{_tail(args_text, '集群目标')}" + ) + + +def build_conductor_prompt(args_text: str = "") -> str: + """`/conductor ` → run `frontends/conductor.py` on the task. + + Upstream `memory/` ships no conductor SOP, so we deliberately keep the + prompt short: name the entrypoint and forward the task verbatim. The + agent is expected to know how to drive `conductor.py` (or consult a + local SOP if one happens to be installed). + """ + args_text = (args_text or "").strip() + if args_text: + return f"请调用 frontends/conductor.py 执行:{args_text}" + return ( + "请调用 frontends/conductor.py,根据后续指令完成多 subagent 编排。" + "若任务描述缺失,先 ask_user 一次性补齐。" + ) + + +# ----- /scheduler reflect-task discovery + launch ------------------------- + +def list_reflect_tasks() -> list[dict]: + """Return [{name, path, doc}] for every reflect/*.py task script. + + `doc` is the module docstring's first line (best-effort) so the picker can + show a one-liner next to each name. Empty list if reflect/ doesn't exist. + """ + out: list[dict] = [] + refl = _ROOT / "reflect" + if not refl.is_dir(): + return out + for p in sorted(refl.glob("*.py")): + if p.name.startswith("_"): + continue + doc = "" + try: + # Cheap docstring sniff: read first ~40 lines, look for """...""". + head = p.read_text(encoding="utf-8", errors="ignore").splitlines()[:40] + joined = "\n".join(head) + for q in ('"""', "'''"): + i = joined.find(q) + if i != -1: + j = joined.find(q, i + 3) + if j != -1: + doc = joined[i + 3:j].strip().splitlines()[0].strip() + break + except Exception: + pass + out.append({"name": p.stem, "path": str(p), "doc": doc}) + return out + + +# ----- hub.pyw parity: every launchable service --------------------------- + +_HUB_EXCLUDES = {"goal_mode.py", "chatapp_common.py", "tuiapp.py"} + + +def _sniff_doc(p) -> str: + """Best-effort first line of a module docstring (cheap ~40-line read).""" + try: + head = p.read_text(encoding="utf-8", errors="ignore").splitlines()[:40] + joined = "\n".join(head) + for q in ('"""', "'''"): + i = joined.find(q) + if i != -1: + j = joined.find(q, i + 3) + if j != -1: + body = joined[i + 3:j].strip() + if body: + return body.splitlines()[0].strip() + except Exception: + pass + return "" + + +def list_launchable_services() -> list[dict]: + """Mirror hub.pyw's discover_services() so `/scheduler` shows the *same* + set of launchable services as the GUI launcher. + + Sources (hub.pyw EXCLUDES = goal_mode.py / chatapp_common.py / tuiapp.py): + • reflect/*.py (not '_'-prefixed, not excluded) + → cmd = [python, agentmain.py, --reflect, reflect/] + • frontends/*app*.py (not excluded) + → 'stapp' → `python -m streamlit run … --server.headless=true` + others → `python frontends/` + + Returns [{name, cmd, doc, kind}] where `name` is the hub-style path + ('reflect/foo.py' / 'frontends/bar.py') and doubles as the picker value. + """ + out: list[dict] = [] + refl = _ROOT / "reflect" + if refl.is_dir(): + for p in sorted(refl.glob("*.py")): + if p.name.startswith("_") or p.name in _HUB_EXCLUDES: + continue + rel = "reflect/" + p.name + out.append({ + "name": rel, + "cmd": [sys.executable, "agentmain.py", "--reflect", rel], + "doc": _sniff_doc(p), + "kind": "reflect", + }) + fe = _ROOT / "frontends" + if fe.is_dir(): + for p in sorted(fe.glob("*.py")): + if "app" not in p.name or p.name in _HUB_EXCLUDES: + continue + rel = "frontends/" + p.name + if "stapp" in p.name: + cmd = [sys.executable, "-m", "streamlit", "run", rel, + "--server.headless=true"] + else: + cmd = [sys.executable, rel] + out.append({"name": rel, "cmd": cmd, "doc": _sniff_doc(p), + "kind": "frontend"}) + return out + + +def start_service(name: str) -> tuple[bool, str]: + """Launch a service from list_launchable_services(), detached & window-less + (CONSTITUTION rule 14: creationflags at the launch layer only, never via + subprocess.Popen monkeypatch). + + `name` accepts the hub-style path ('reflect/foo.py') or a bare reflect stem + ('foo') for backward-compat with `/scheduler start `. + """ + svcs = list_launchable_services() + svc = next((s for s in svcs if s["name"] == name), None) + if svc is None: # bare reflect stem fallback + cand = "reflect/" + name + ".py" + svc = next((s for s in svcs if s["name"] == cand), None) + if svc is None: + return False, f"未知服务: {name}" + try: + flags = 0 + if os.name == "nt": + flags = 0x00000200 | 0x08000000 # NEW_PROCESS_GROUP | NO_WINDOW + proc = subprocess.Popen( + svc["cmd"], + cwd=str(_ROOT), + creationflags=flags, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + ) + # Poll-and-confirm: if the child dies immediately (bad path, import + # error, port-in-use, etc) Popen still returns happily — without this + # check the picker would tick "✅ started" while nothing is running, + # which is exactly the bug#4 the user hit. 0.4s is the smallest + # window that catches "explodes at import" without making the UI + # feel laggy on healthy starts. + time.sleep(0.4) + rc = proc.poll() + if rc is not None: + return False, f"启动失败 (退出码 {rc}): {svc['name']}" + invalidate_running_cache() + return True, f"已启动 {svc['name']} (pid={proc.pid})" + except Exception as e: + return False, f"启动失败: {type(e).__name__}: {e}" + + +# ----- running-state introspection (bug#4) -------------------------------- +# Why psutil cmdline-scan instead of a launched-by-us pid registry? +# • Services launched by a previous TUI run, or by hub.pyw, must also be +# recognised — otherwise /scheduler would happily start a duplicate. +# • A registry tied to this process dies when the TUI restarts, but the +# services keep running (CREATE_NEW_PROCESS_GROUP). Cmdline scan is the +# only single source of truth across launchers, surviving restarts. +# Trade-off: it costs ~30ms per /scheduler open, and matches by cmdline tail, +# so two checkouts of GA can collide. We accept that — running two GAs out +# of two clones is already an unsupported configuration. + +def _match_service(cmdline: list[str], svc: dict) -> bool: + """Does this OS process belong to `svc`? Match on the trailing script + arg (`reflect/foo.py` for reflect tasks, `frontends/bar.py` for apps), + which is invariant across `python` vs `pythonw` vs venv shims. + + Reflect detection used to require BOTH `agentmain.py` AND the reflect + path in cmdline. That rejected tasks launched directly (`python + reflect/scheduler.py`) by launch.pyw, dev scripts, or by an earlier + TUI run that used a different launcher — they showed unticked in + /scheduler even when alive. Path-only match handles both styles; the + Python-process pre-filter in `running_services` keeps false positives + (greps, editors with the file open) from sneaking in.""" + if not cmdline: + return False + rel = svc["name"] # 'reflect/foo.py' | 'frontends/bar.py' + rel_norm = rel.replace("/", os.sep) + return any(rel_norm in (a or "") or rel in (a or "") + for a in cmdline) + + +# 2s TTL cache + name-prefilter: ~2.1s → ~1.0s cold, ~0ms warm. +# cmdline() is the per-proc cost; only pay it for python-ish survivors. +_RUNNING_CACHE: tuple[float, dict[str, int]] | None = None +_RUNNING_TTL = 2.0 + + +def invalidate_running_cache() -> None: + """Drop the snapshot. Call after start/stop so the next read is fresh.""" + global _RUNNING_CACHE + _RUNNING_CACHE = None + + +def running_services(use_cache: bool = True) -> dict[str, int]: + """{service_name: pid} for live services. {} if psutil missing.""" + global _RUNNING_CACHE + if use_cache and _RUNNING_CACHE and time.time() - _RUNNING_CACHE[0] < _RUNNING_TTL: + return dict(_RUNNING_CACHE[1]) + try: + import psutil # type: ignore + except Exception: + return {} + svcs = list_launchable_services() + out: dict[str, int] = {} + me = os.getpid() + for proc in psutil.process_iter(["pid", "name"]): + try: + if proc.info["pid"] == me: + continue + nm = (proc.info.get("name") or "").lower() + if "python" not in nm and "py.exe" not in nm: + continue + cmd = proc.cmdline() + except Exception: + continue + for svc in svcs: + if svc["name"] not in out and _match_service(cmd, svc): + out[svc["name"]] = proc.info["pid"] + break + _RUNNING_CACHE = (time.time(), dict(out)) + return out + + +def stop_service(name: str) -> tuple[bool, str]: + """Terminate the service `name` if running. Returns (ok, message). + + Sends SIGTERM-equivalent (Popen.terminate on Windows = TerminateProcess), + waits up to 3s, then escalates to kill. Also reaps obvious children + (e.g. `python -m streamlit` spawns the actual streamlit worker) so we + don't leave orphans behind. + """ + try: + import psutil # type: ignore + except Exception: + return False, "未安装 psutil,无法停止服务" + running = running_services() + pid = running.get(name) + if pid is None: + return False, f"{name} 未在运行" + try: + parent = psutil.Process(pid) + kids = parent.children(recursive=True) + for p in [parent, *kids]: + try: + p.terminate() + except Exception: + pass + gone, alive = psutil.wait_procs([parent, *kids], timeout=3.0) + for p in alive: + try: + p.kill() + except Exception: + pass + invalidate_running_cache() + return True, f"已停止 {name} (pid={pid})" + except psutil.NoSuchProcess: + invalidate_running_cache() + return True, f"{name} 已退出" + except Exception as e: + return False, f"停止失败: {type(e).__name__}: {e}" + + +def list_scheduler_tasks() -> list[dict]: + """Return [{name, path, schedule, enabled}] for every sche_tasks/*.json. + + Used by the /scheduler picker so users can also toggle traditional cron + tasks, not just reflect.* scripts. + """ + out: list[dict] = [] + sd = _ROOT / "sche_tasks" + if not sd.is_dir(): + return out + for p in sorted(sd.glob("*.json")): + try: + data = json.loads(p.read_text(encoding="utf-8")) + except Exception: + data = {} + out.append({ + "name": p.stem, + "path": str(p), + "schedule": data.get("schedule") or data.get("cron") or data.get("every") or "", + "enabled": bool(data.get("enabled", True)), + }) + return out + + +def start_reflect_task(name: str) -> tuple[bool, str]: + """Spawn `python reflect/.py` detached. Returns (ok, message). + + Detached because reflect tasks are long-running; we don't want them to die + with the TUI. On Windows we use CREATE_NEW_PROCESS_GROUP|CREATE_NO_WINDOW + so no console pops up (per CONSTITUTION rule 14: only at launch layer, no + monkeypatching subprocess.Popen). + """ + script = _ROOT / "reflect" / f"{name}.py" + if not script.is_file(): + return False, f"reflect/{name}.py 不存在" + try: + flags = 0 + if os.name == "nt": + flags = 0x00000200 | 0x08000000 # NEW_PROCESS_GROUP | NO_WINDOW + subprocess.Popen( + [sys.executable, str(script)], + cwd=str(_ROOT), + creationflags=flags, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + ) + return True, f"已启动 reflect/{name}.py" + except Exception as e: + return False, f"启动失败: {type(e).__name__}: {e}" + + +# ----- dispatch table for the TUI to register against --------------------- + +# (cmd, arg_hint, desc) — kept identical between v2 and v3 so the palette +# stays consistent across frontends. +PALETTE_ENTRIES: list[tuple[str, str, str]] = [ + ("/update", "[note]", "git pull 更新 GA 仓库并报告影响面"), + ("/autorun", "[seed]", "进入 autonomous_operation 自主模式"), + ("/morphling", "[target]", "启用 Morphling 蒸馏 / 吞噬外部技能"), + ("/goal", "[goal]", "进入 Goal 模式(需 condition 约束)"), + ("/hive", "[target]", "进入 Hive 多 worker 协作模式"), + ("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"), + ("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"), + ("/resume", "", "列出最近会话并恢复其中一个(GA 端展开 prompt)"), +] + + +def prompt_for(cmd: str, args_text: str) -> Optional[str]: + """Return the injected user-message for a given slash command, or None if + the command isn't one of ours (e.g. /scheduler — handled by TUI directly). + + Language is resolved inside the builders that care about it (see + `_current_lang()`); callers never thread it through, so both TUIs keep a + single uniform call site. + """ + table = { + "/update": build_update_prompt, + "/autorun": build_autorun_prompt, + "/morphling": build_morphling_prompt, + "/goal": build_goal_prompt, + "/hive": build_hive_prompt, + "/conductor": build_conductor_prompt, + } + fn = table.get(cmd) + return fn(args_text) if fn else None diff --git a/frontends/stapp.py b/frontends/stapp.py new file mode 100644 index 0000000..3792d00 --- /dev/null +++ b/frontends/stapp.py @@ -0,0 +1,408 @@ +import os, sys, subprocess +from urllib.request import urlopen +from urllib.parse import quote +if sys.stdout is None: sys.stdout = open(os.devnull, "w") +if sys.stderr is None: sys.stderr = open(os.devnull, "w") +try: sys.stdout.reconfigure(errors='replace') +except: pass +try: sys.stderr.reconfigure(errors='replace') +except: pass +script_dir = os.path.dirname(__file__) +sys.path.append(os.path.abspath(os.path.join(script_dir, '..'))) +sys.path.append(os.path.abspath(script_dir)) + +import streamlit as st +import time, json, re, threading, queue +from datetime import timedelta +from agentmain import GeneraticAgent +import chatapp_common # activate /continue command (monkey patches GeneraticAgent) +from continue_cmd import handle_frontend_command, reset_conversation, list_sessions, extract_ui_messages +from btw_cmd import handle_frontend_command as btw_handle_frontend +from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard + +st.set_page_config(page_title="Cowork", layout="wide", initial_sidebar_state="collapsed") + +st.markdown(""" + +""", unsafe_allow_html=True) + +LANG = os.environ.get('GA_LANG', 'zh') +if LANG not in ('zh', 'en'): LANG = 'zh' +I18N = { + 'zh': { + 'force_stop': '强行停止任务', + 'desktop_pet': '🐱 桌面宠物', + 'suggest_btn': '🎯 给我找点事做', + 'suggest_prompt': '按照自主行动的规划部分,充分分析我的情况,给我生成一批TODO,务必让我感兴趣', + 'auto_start': '开始空闲自主行动', + 'auto_pause': '⏸️ 禁止自主行动', + 'auto_enable': '▶️ 允许自主行动', + 'auto_on_cap': '🟢 自主行动运行中,会在你离开它30分钟后自动进行', + 'auto_off_cap': '🔴 自主行动已停止', + 'auto_prompt': '[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。', + }, + 'en': { + 'force_stop': 'Force Stop', + 'desktop_pet': '🐱 Desktop Pet', + 'suggest_btn': '🎯 Suggest tasks', + 'suggest_prompt': 'Following the planning section of autonomous sop, analyze my situation thoroughly and generate a batch of TODOs that will interest me.', + 'auto_start': 'Start idle auto-action', + 'auto_pause': '⏸️ Pause auto-action', + 'auto_enable': '▶️ Enable auto-action', + 'auto_on_cap': '🟢 Auto-action enabled, triggers after 30min idle', + 'auto_off_cap': '🔴 Auto-action disabled', + 'auto_prompt': '[AUTO]🤖 User has been idle for over 30 minutes. As an autonomous agent, read the automation SOP and execute automatic tasks.', + }, +} +def T(key): return I18N.get(LANG, I18N['zh']).get(key, key) + +@st.cache_resource +def init(): + agent = GeneraticAgent() + if agent.llmclient is None: + st.error("⚠️ Please set mykey.py!") + st.stop() + else: threading.Thread(target=agent.run, daemon=True).start() + return agent + +agent = init() + +def build_prompt(objective): + return f"""读取 {agent.log_path} 尾部,获取 agent 的最新输出。 +用户的 loop 诉求:{objective} +判断该 agent 是否偷懒、是否真正完成诉求,用 输出要追加给它的指令: +一般复述 objective,或不超过 10 字的**督促**,如:别停,继续 / 这就叫最优?你优化到位了吗 / 看我要求,你达成了吗 / 你好好看清楚 / 你能不能看看记忆 / 把关键发现和阶段性成果落盘,然后继续 +不允许促进 agent 停止或代替宣告任务完成,只允许催促不要对原任务进行评价,特别**禁止**“任务已完成,结束”这种让agent结束的指令,你的任务是让agent继续loop而非停止。 +只输出 ,若需要停止则不要输出此tag。 +""" + +@st.cache_resource +def get_controller(): + b = {'ev': threading.Event(), 'obj': '', 'out': None, 'ready': False} + def loop(): + ag = GeneraticAgent(); ag.verbose = False; ag.log_path = False + threading.Thread(target=ag.run, daemon=True).start() + while True: + b['ev'].wait(); b['ev'].clear() + if ag.llm_no != agent.llm_no: ag.next_llm(agent.llm_no) + dq = ag.put_task(build_prompt(b['obj']), source="controller") + while 'done' not in (it := dq.get()): pass + ms = re.findall(r'(.*?)', it['done'], re.S) + b['out'] = ms[-1].strip() if ms else None; b['ready'] = True + threading.Thread(target=loop, daemon=True).start(); return b + +st.title("🖥️ Cowork") + +st.session_state.setdefault('autonomous_enabled', False) + +@st.fragment +def render_sidebar(): + st.session_state.setdefault('autonomous_enabled', False) + llm_options = agent.list_llms() + current_idx = agent.llm_no + llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options} + st.caption(f"LLM Core: {llm_labels.get(current_idx, str(current_idx))}") + selected_idx = st.selectbox("LLM", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, label_visibility="collapsed", key="sidebar_llm_select") + if selected_idx != current_idx: + agent.next_llm(selected_idx); st.rerun(scope="fragment") + if st.button(T('force_stop')): + agent.abort(); st.toast("Stop signal sended"); st.rerun() + if st.button(T('desktop_pet')): + kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {} + pet_script = os.path.join(script_dir, 'desktop_pet_v2.pyw') + if not os.path.exists(pet_script): + st.error("desktop_pet_v2.pyw not found") + return + subprocess.Popen([sys.executable, pet_script], **kwargs) + def _pet_req(q): + def _do(): + try: urlopen(f'http://127.0.0.1:41983/?{q}', timeout=2) + except Exception: pass + threading.Thread(target=_do, daemon=True).start() + agent._pet_req = _pet_req + if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {} + def _pet_hook(ctx): + parts = [f"Turn {ctx.get('turn','?')}"] + if ctx.get('summary'): parts.append(ctx['summary']) + if ctx.get('exit_reason'): parts.append('DONE') + _pet_req(f'msg={quote(chr(10).join(parts))}') + if ctx.get('exit_reason'): _pet_req('state=idle') + agent._turn_end_hooks['pet'] = _pet_hook + st.toast("Desktop pet started") + + if st.button(T('suggest_btn')): + st.session_state['_inject_prompt'] = T('suggest_prompt') + st.rerun(scope="app") + st.divider() + st.markdown("""""", unsafe_allow_html=True) + st.text_area("Loop prompt", value=st.session_state.get('loop_prompt_input', "继续" if LANG=='zh' else 'next'), key="loop_prompt_input", height=1) + if st.session_state.get('loop_enabled'): + if st.button("⏹️ Stop Loop"): + st.session_state.loop_enabled = False + st.toast("⏹️ Loop stopped"); st.rerun(scope="app") + st.caption("🔁 Looping") + else: + if st.button("🔁 Loop!"): + st.session_state.loop_enabled = True + get_controller() + st.session_state['_inject_prompt'] = st.session_state.get('loop_prompt_input', '') + st.toast("🔁 Looping"); st.rerun(scope="app") + st.divider() + if st.button(T('auto_start')): + st.session_state.last_reply_time = int(time.time()) - 1800 + st.session_state.autonomous_enabled = True + st.rerun(scope="app") + if st.session_state.autonomous_enabled: + if st.button(T('auto_pause')): + st.session_state.autonomous_enabled = False + st.toast(T('auto_pause')); st.rerun(scope="app") + st.caption(T('auto_on_cap')) + else: + if st.button(T('auto_enable'), type="primary"): + st.session_state.autonomous_enabled = True + st.toast("✅"); st.rerun(scope="app") + st.caption(T('auto_off_cap')) +with st.sidebar: render_sidebar() + +def fold_turns(text): + """Return list of segments: [{'type':'text','content':...}, {'type':'fold','title':...,'content':...}]""" + # 先把4+反引号块替换为占位符,避免误切子agent嵌套的 LLM Running + _ph = [] + safe = re.sub(r'`{4,}.*?`{4,}', lambda m: (_ph.append(m.group(0)), f'\x00PH{len(_ph)-1}\x00')[1], text, flags=re.DOTALL) + # 流式中间态:末尾可能有未闭合的4+反引号块,也需保护 + safe = re.sub(r'`{4,}[^`].*$', lambda m: (_ph.append(m.group(0)), f'\x00PH{len(_ph)-1}\x00')[1], safe, flags=re.DOTALL) + parts = re.split(r'(\**LLM Running \(Turn \d+\) \.\.\.\*\**)', safe) + parts = [re.sub(r'\x00PH(\d+)\x00', lambda m: _ph[int(m.group(1))], p) for p in parts] + if len(parts) < 4: return [{'type': 'text', 'content': text}] + segments = [] + if parts[0].strip(): segments.append({'type': 'text', 'content': parts[0]}) + turns = [] + for i in range(1, len(parts), 2): + marker = parts[i] + content = parts[i+1] if i+1 < len(parts) else '' + turns.append((marker, content)) + for idx, (marker, content) in enumerate(turns): + if idx < len(turns) - 1: + _c = re.sub(r'`{3,}.*?`{3,}|.*?', '', content, flags=re.DOTALL) + matches = re.findall(r'\s*((?:(?!).)*?)\s*', _c, re.DOTALL) + if matches: + title = matches[0].strip() + title = title.split('\n')[0] + if len(title) > 50: title = title[:50] + '...' + else: + _plain = _c.strip().split('\n', 1)[0] + title = (_plain[:50] + '...') if len(_plain) > 50 else (_plain or marker.strip('*')) + segments.append({'type': 'fold', 'title': title, 'content': content}) + else: segments.append({'type': 'text', 'content': marker + content}) + return segments +_SUMMARY_TAG_RE = re.compile(r'.*?\s*', re.DOTALL) + +def render_segments(segments, suffix=''): + # 整块重画:调用方用 slot.container() 包裹,保证 DOM 路径稳定、跨 rerun 对齐(消除"灰色重影")。 + # heartbeat 空转时 segments 不变 → Streamlit 后端 diff 无变化 → 前端零闪烁; + # 但 container/markdown 本身是 API 调用,StopException 仍会被抛出(abort 照常起作用)。 + for seg in segments: + if seg['type'] == 'fold': + with st.expander(seg['title'], expanded=False): st.markdown(seg['content']) + else: + st.markdown(seg['content'] + suffix) + +def agent_backend_stream(prompt=None): + """Drain main task display_queue. + - prompt given: start a fresh task; new dq is kept in session_state. + - prompt is None: resume a dq left in session_state by a prior run (e.g. after /btw). + Per-chunk progress is mirrored to session_state.partial_response so the rendered + bubble survives reruns. No implicit agent.abort() — explicit stop is on the Stop button.""" + if prompt is not None: + st.session_state.display_queue = agent.put_task(prompt, source="user") + st.session_state.partial_response = '' + dq = st.session_state.get('display_queue') + if dq is None: return + # Drop a dangling 'LLM Running (Turn N) ...' marker if the captured partial + # ended right at a turn boundary with no content yet — otherwise the resume + # bubble flashes as a marker-only gray line. The marker reappears with + # content on the next chunk (raw_resp is cumulative). + response = re.sub(r'\**LLM Running \(Turn \d+\) \.\.\.\**\s*$', + '', st.session_state.get('partial_response', '')).rstrip() + try: + while True: + try: item = dq.get(timeout=1) + except queue.Empty: + yield response # heartbeat: let outer st.markdown() run → Streamlit checks StopException + continue + if 'next' in item: + response = item['next'] + st.session_state.partial_response = response + yield response + if 'done' in item: + st.session_state.display_queue = None + st.session_state.partial_response = '' + yield item['done']; break + finally: + agent.abort() + try: + st.session_state.display_queue = None + st.session_state.partial_response = '' + except BaseException: + pass + + +def render_main_stream(prompt=None): + """Render the assistant bubble for the main task (new or resumed). Saves final to messages.""" + with st.chat_message("assistant"): + frozen = 0; live = st.empty(); response = '' + CURSOR = ' ▌' + for response in agent_backend_stream(prompt): + segs = fold_turns(response) + n_done = max(0, len(segs) - 1) + while frozen < n_done: + with live.container(): render_segments([segs[frozen]]) + live = st.empty(); frozen += 1 + with live.container(): render_segments([segs[-1]], suffix=CURSOR) # live 区域 + segs = fold_turns(response) + for i in range(frozen, len(segs)): + with live.container(): render_segments([segs[i]]) + if i < len(segs) - 1: live = st.empty() + if response: + st.session_state.messages.append({"role": "assistant", "content": response}) + st.session_state.last_reply_time = int(time.time()) + # ── 循环回调:回答完成戳醒 controller 决策(去程,现取最新objective) ── + if st.session_state.get('loop_enabled'): + b = get_controller() + b['obj'] = st.session_state.get('loop_prompt_input', ''); b['ready'] = False; b['ev'].set() + +if not hasattr(agent, "_ui_messages"): agent._ui_messages = st.session_state.get("messages", []) +if "messages" not in st.session_state: st.session_state.messages = agent._ui_messages +for msg in st.session_state.messages: + with st.chat_message(msg["role"]): + # 用 slot=st.empty() + with slot.container(): ... 的外壳,DOM 路径和流式渲染完全一致,跨 rerun 对齐 + slot = st.empty() + with slot.container(): + if msg["role"] == "assistant": render_segments(fold_turns(msg["content"])) + else: st.markdown(msg["content"]) + +# Scroll-height ghost fix: during streaming, expander open/close mid-animation can leave +# phantom height → scrollbar long but can't scroll to bottom. Periodically detect & reflow. +try: + from streamlit import iframe as _st_iframe # 1.56+ + _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()}) +except (ImportError, AttributeError): + from streamlit.components.v1 import html as _embed_html # ≤1.55 +# IME composition fix (macOS only) - prevents Enter from submitting during CJK input +_js_ime_fix = ("" if os.name == 'nt' else + "!function(){if(window.parent.__imeFix)return;window.parent.__imeFix=1;" + "var d=window.parent.document,c=0;" + "d.addEventListener('compositionstart',()=>c=1,!0);" + "d.addEventListener('compositionend',()=>c=0,!0);" + "function f(){d.querySelectorAll('textarea[data-testid=stChatInputTextArea]')" + ".forEach(t=>{t.__imeFix||(t.__imeFix=1,t.addEventListener('keydown',e=>{" + "e.key==='Enter'&&!e.shiftKey&&(e.isComposing||c||e.keyCode===229)&&" + "(e.stopImmediatePropagation(),e.preventDefault())},!0))})}" + "f();new MutationObserver(f).observe(d.body,{childList:1,subtree:1})}()") +_embed_html(f'', height=0) + +_injected = st.session_state.pop('_inject_prompt', None) +prompt = st.chat_input("any task?") or _injected +if prompt: + ts = time.strftime("%Y-%m-%d %H:%M:%S") + cmd = (prompt or "").strip() + def _reset_and_rerun(): + st.session_state.streaming = False + st.session_state.stopping = False + st.session_state.display_queue = None + st.session_state.partial_response = "" + st.session_state.reply_ts = "" + st.session_state.current_prompt = "" + st.session_state.last_reply_time = int(time.time()) + st.rerun() + if cmd == "/new": + st.session_state.messages = [{"role": "assistant", "content": reset_conversation(agent), "time": ts}] + _reset_and_rerun() + if cmd.startswith("/continue"): + m = re.match(r'/continue\s+(\d+)\s*$', cmd.strip()) + sessions = list_sessions(exclude_pid=os.getpid()) if m else [] + idx = int(m.group(1)) - 1 if m else -1 + # Resolve target path BEFORE handle (which snapshots current log, shifting indices). + target = sessions[idx][0] if 0 <= idx < len(sessions) else None + result = handle_frontend_command(agent, cmd) + history = extract_ui_messages(target) if target and result.startswith('✅') else None + tail = [{"role": "assistant", "content": result, "time": ts}] + if history: st.session_state.messages = history + tail + else: st.session_state.messages = list(st.session_state.messages)+[{"role": "user", "content": cmd, "time": ts}]+tail + _reset_and_rerun() + if cmd.startswith("/btw"): + answer = btw_handle_frontend(agent, cmd) # sync; bypasses put_task → main agent.run() untouched + st.session_state.messages = list(st.session_state.messages) + [ + {"role": "user", "content": prompt, "time": ts}, + {"role": "assistant", "content": answer, "time": ts}, + ] + st.rerun() # preserve display_queue/partial_response so resume path drains the running main task + if cmd.startswith("/export"): + parts = cmd.split(maxsplit=1) + sub = parts[1].strip() if len(parts) > 1 else "" + sub_lower = sub.lower() + if not sub: + result = ( + "**选择导出方式:**\n\n" + "- `/export clip` — 整理到代码块中\n" + "- `/export <文件名>` — 导出到 `temp/<文件名>`(默认 .md 后缀)\n" + "- `/export all` — 显示完整对话日志路径" + ) + elif sub_lower == "all": + log = agent.log_path + result = (f"📂 完整对话日志:\n\n`{log}`" if os.path.isfile(log) + else f"❌ 当前会话尚无日志文件") + else: + text = last_assistant_text(agent) + if not text: + result = "❌ 还没有模型回复可导出" + elif sub_lower in ("clip", "copy"): + result = f"📋 最后一轮回复(点代码块右上角 📋 复制):\n\n{wrap_for_clipboard(text)}" + else: + try: + path = export_to_temp(text, sub) + result = f"✅ 已导出:\n\n`{path}`" + except Exception as e: + result = f"❌ 导出失败: {e}" + st.session_state.messages = list(st.session_state.messages) + [ + {"role": "user", "content": cmd, "time": ts}, + {"role": "assistant", "content": result, "time": ts}, + ] + _reset_and_rerun() + # Regular prompt: any in-flight task will be aborted by the finally block in + # agent_backend_stream when StopException interrupts the prior generator. + st.session_state.messages.append({"role": "user", "content": prompt}) + if hasattr(agent, '_pet_req') and not prompt.startswith('/'): agent._pet_req('state=walk') + with st.chat_message("user"): st.markdown(prompt) + render_main_stream(prompt) +elif st.session_state.get('display_queue') is not None: + # No new prompt but a task is mid-flight (typically a /btw rerun) — resume drain. + render_main_stream() + +# ── 空闲自主行动:fragment 定时检测,替代 launch.pyw 的 idle_monitor ── +@st.fragment(run_every=timedelta(minutes=1)) +def _idle_checker(): + if st.session_state.get('loop_enabled'): + b = get_controller() + if b['ready']: + b['ready'] = False + if b['out'] and '停止循环' not in b['out']: st.session_state['_inject_prompt'] = b['out'] + else: st.session_state.loop_enabled = False + st.rerun(scope="app") + return + if not st.session_state.get('autonomous_enabled'): return + if st.session_state.get('display_queue') is not None: return # 正在运行中 + last = st.session_state.get('last_reply_time', int(time.time())) + if time.time() - last > 1800: + st.session_state['_inject_prompt'] = T('auto_prompt') + st.session_state['last_reply_time'] = int(time.time()) # 防重入 + st.rerun(scope="app") +_idle_checker() diff --git a/frontends/stapp2.py b/frontends/stapp2.py new file mode 100644 index 0000000..1d7968f --- /dev/null +++ b/frontends/stapp2.py @@ -0,0 +1,1049 @@ +import os, sys +import html +if sys.stdout is None: sys.stdout = open(os.devnull, "w") +if sys.stderr is None: sys.stderr = open(os.devnull, "w") +try: sys.stdout.reconfigure(errors='replace') +except: pass +try: sys.stderr.reconfigure(errors='replace') +except: pass +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +import streamlit as st +try: + from streamlit import iframe as _st_iframe # 1.56+ + _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()}) +except (ImportError, AttributeError): + from streamlit.components.v1 import html as _embed_html # ≤1.55 +import time, json, re, threading, queue +from datetime import datetime +from agentmain import GeneraticAgent + +st.set_page_config(page_title="Cowork", layout="wide") + +# ─── Anthropic Light Theme CSS ─── +ANTHROPIC_CSS = """ + +""" + +ANTHROPIC_SELECTBOX_SCRIPT = """ +
+ +""" + +@st.cache_resource +def init(): + agent = GeneraticAgent() + if agent.llmclient is None: + st.error("⚠️ 未配置任何可用的 LLM 接口,请在 mykey.py 中添加 sider_cookie 或 oai_apikey+oai_apibase 等信息后重启。") + st.stop() + else: + threading.Thread(target=agent.run, daemon=True).start() + return agent + + +def build_dynamic_font_css(scale_percent: float) -> str: + root_percent = max(100.0, min(200.0, float(scale_percent))) + rem_scale = root_percent / 100.0 + return f""" + +""" + + +def build_dynamic_font_update_script(scale_percent: float) -> str: + css = json.dumps(build_dynamic_font_css(scale_percent)) + return f""" + +""" + + +def build_header_agent_badge_script() -> str: + return """ + +""" + +agent = init() + +def init_session_state(): + for key, value in { + 'agent_name': 'GenericAgent', 'streaming': False, 'stopping': False, 'display_queue': None, + 'partial_response': '', 'reply_ts': '', 'current_prompt': '', 'selected_llm_idx': agent.llm_no, + 'autonomous_enabled': False, 'messages': [], + }.items(): st.session_state.setdefault(key, value) + +init_session_state() + +# Inject Anthropic theme +st.markdown(ANTHROPIC_CSS, unsafe_allow_html=True) +st.markdown(build_dynamic_font_css(110.0), unsafe_allow_html=True) +_embed_html(ANTHROPIC_SELECTBOX_SCRIPT, height=0, width=0) +_embed_html(build_header_agent_badge_script(), height=0, width=0) + +st.session_state.agent_name = 'Generic Agent' +with st.chat_message("assistant"): + st.markdown(f'
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
', unsafe_allow_html=True) + st.write("欢迎使用GenericAgent~") + + +@st.fragment +def render_sidebar(): + llm_options, current_idx = agent.list_llms(), agent.llm_no + st.session_state.selected_llm_idx = current_idx + llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options} + st.caption(f"当前使用的LLM为:{current_idx}: {agent.get_llm_name()}", help="可在下方选择链路") + st.markdown(f'
{html.escape(max(llm_labels.values(), key=len, default=""))}
', unsafe_allow_html=True) + selected_idx = st.selectbox("选择链路:", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, key="sidebar_llm_select") + if selected_idx != current_idx: + agent.next_llm(selected_idx) + st.session_state.selected_llm_idx = selected_idx + st.toast(f"已切换到备用链路:{llm_labels[selected_idx]}") + st.rerun() + st.divider() + if st.button("重新注入System Prompt"): + agent.llmclient.last_tools = '' + st.toast("下次将重新注入System Prompt") + +with st.sidebar: render_sidebar() + + +def start_agent_task(prompt): + st.session_state.display_queue = agent.put_task(prompt, source="user") + st.session_state.streaming, st.session_state.stopping, st.session_state.partial_response = True, False, '' + st.session_state.reply_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + st.session_state.current_prompt = prompt + + +def poll_agent_output(max_items=20): + q = st.session_state.display_queue + if q is None: + st.session_state.streaming = False + return False + done = False + for _ in range(max_items): + try: + item = q.get_nowait() + except queue.Empty: + break + if 'next' in item: st.session_state.partial_response = item['next'] + if 'done' in item: + st.session_state.partial_response = item['done'] + done = True + break + if done: st.session_state.streaming = st.session_state.stopping = False; st.session_state.display_queue = None + return done + + +def _get_response_segments(text): + return [p for p in re.split(r'(?=\*\*LLM Running \(Turn \d+\) \.\.\.\*\*)', text) if p.strip()] or [text] + +def render_message(role, content, ts='', unsafe_allow_html=True): + with st.chat_message(role): + if ts: st.markdown(f'
{ts}
', unsafe_allow_html=True) + st.markdown(content, unsafe_allow_html=unsafe_allow_html) + +def finish_streaming_message(): + reply_ts = st.session_state.reply_ts + st.session_state.messages.extend({"role": "assistant", "content": seg, "time": reply_ts} for seg in _get_response_segments(st.session_state.partial_response)) + st.session_state.last_reply_time = int(time.time()) + st.session_state.partial_response = st.session_state.reply_ts = st.session_state.current_prompt = '' + +def render_streaming_area(): + if not st.session_state.streaming: return + with st.container(): + st.markdown('', unsafe_allow_html=True) + if st.button("⏹️ 停止生成", type="primary"): + agent.abort(); st.session_state.stopping = True; st.toast("已发送停止信号"); st.rerun() + reply_ts = st.session_state.reply_ts + with st.empty().container(): + segments = _get_response_segments(st.session_state.partial_response) + for i, seg in enumerate(segments): render_message("assistant", seg + ("" if i < len(segments) - 1 else "▌"), ts=reply_ts, unsafe_allow_html=False) + if poll_agent_output(): finish_streaming_message() + else: time.sleep(0.2) + st.rerun() + +for msg in st.session_state.messages: render_message(msg["role"], msg["content"], ts=msg.get("time", ""), unsafe_allow_html=True) +if st.session_state.streaming: render_streaming_area() +if prompt := st.chat_input("请输入指令", disabled=st.session_state.streaming): + st.session_state.messages.append({"role": "user", "content": prompt, "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}) + start_agent_task(prompt) + st.rerun() + diff --git a/frontends/tgapp.py b/frontends/tgapp.py new file mode 100644 index 0000000..ea3b3a1 --- /dev/null +++ b/frontends/tgapp.py @@ -0,0 +1,1138 @@ +import os, sys, re, threading, asyncio, queue as Q, time, random, uuid +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp') +from agentmain import GeneraticAgent +try: + from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup + from telegram.constants import ChatType, MessageLimit, ParseMode + from telegram.error import RetryAfter + from telegram.ext import ApplicationBuilder, CallbackQueryHandler, MessageHandler, filters, ContextTypes + from telegram.helpers import escape_markdown + from telegram.request import HTTPXRequest +except: + print("Please ask the agent install python-telegram-bot to use telegram module.") + sys.exit(1) +from chatapp_common import ( + FILE_HINT, + HELP_TEXT, + TELEGRAM_MENU_COMMANDS, + clean_reply, + ensure_single_instance, + extract_files, + format_restore, + redirect_log, + require_runtime, + split_text, +) +from continue_cmd import handle_frontend_command, reset_conversation +from btw_cmd import handle_frontend_command as handle_btw_frontend_command +from review_cmd import handle as handle_review_command +from llmcore import mykeys + +agent = GeneraticAgent() +agent.verbose = False +agent.inc_out = True +ALLOWED = set(mykeys.get('tg_allowed_users', [])) + +_DRAFT_HINT = "thinking..." +_STREAM_SUFFIX = " ⏳" +_STREAM_SEGMENT_LIMIT = max(1200, MessageLimit.MAX_TEXT_LENGTH - 256) +_STREAM_UPDATE_INTERVAL_SECONDS = 2.0 +_STREAM_MIN_UPDATE_CHARS = 400 +_RETRY_AFTER_MARGIN_SECONDS = 1.0 +_QUEUE_WAIT_SECONDS = 1 +_ASK_USER_HOOK_KEY = "telegram_ask_user_menu" +_ASK_CALLBACK_PREFIX = "ask:" +_LLM_CALLBACK_PREFIX = "llm:" +_ASK_CANCEL_ACTION = "none" +_ASK_MULTI_DONE_ACTION = "done" +_ASK_TOGGLE_ACTION = "toggle" +_ASK_CANCEL_LABEL = "none of these above" +_ASK_CANCEL_PROMPT = "已取消选择,请直接发送下一步操作。" +_ASK_MULTI_HINT = "可多选:点选项目后点击 Done 提交。" +_ASK_MULTI_EMPTY_HINT = "请至少选择一项,或选择 none of these above。" +_LLM_MENU_PROMPT = "请选择要切换的 LLM:" +_ask_menu_events = Q.Queue() +_ask_menu_store = {} +_llm_menu_store = {} +_MULTI_SELECT_RE = re.compile(r"\[?(?:多选|multi(?:[-_ ]?select)?|select all)\]?", re.IGNORECASE) +_QUOTE_OPEN_TAG = "<_quote_>" +_QUOTE_CLOSE_TAG = "" +_QUOTE_TOKEN_PATTERN = re.escape(_QUOTE_OPEN_TAG) + r"([\s\S]*?)" + re.escape(_QUOTE_CLOSE_TAG) +_MD_TOKEN_RE = re.compile( + ( + r"(`{3,})([A-Za-z0-9_+-]*)\n([\s\S]*?)\1" + r"|" + _QUOTE_TOKEN_PATTERN + + r"|\[([^\]]+)\]\(([^)\n]+)\)" + r"|`([^`\n]+)`" + r"|\*\*([^\n]+?)\*\*" + r"|__([^\n]+?)__" + r"|~~([^\n]+?)~~" + r"|(?\s*(.*?)\s*
", re.DOTALL) +_TURN_SUMMARY_SEARCH_STRIP_RE = re.compile(r"`{3,}[\s\S]*?`{3,}|[\s\S]*?", re.DOTALL) + +def _make_draft_id(): + return random.randint(1, 2**31 - 1) + +def _visible_segments(text): + text = (text or "").strip() + if not text: + return [] + segments = [] + for part in split_text(text, _STREAM_SEGMENT_LIMIT): + segments.extend(_markdown_safe_segments(part)) + return segments + +def _markdown_safe_segments(text, limit=None): + limit = limit or MessageLimit.MAX_TEXT_LENGTH + text = (text or "").strip() + if not text: + return [] + if len(_to_markdown_v2(text)) <= limit: + return [text] + parts = [] + remaining = text + while remaining: + if len(_to_markdown_v2(remaining)) <= limit: + parts.append(remaining) + break + low, high, best = 1, len(remaining), 1 + while low <= high: + mid = (low + high) // 2 + if len(_to_markdown_v2(remaining[:mid].rstrip() or remaining[:mid])) <= limit: + best = mid + low = mid + 1 + else: + high = mid - 1 + cut = remaining.rfind("\n", 0, best) + if cut < max(1, best * 0.6): + cut = best + chunk = remaining[:cut].rstrip() or remaining[:best] + parts.append(chunk) + remaining = remaining[len(chunk):].lstrip() + return parts + +def _line_complete(line): + return (line or "").endswith(("\n", "\r")) + +def _turn_marker_number(line): + match = _TURN_MARKER_RE.fullmatch((line or "").strip()) + return int(match.group(1)) if match else None + +def _maybe_partial_turn_marker(line): + text = (line or "").strip().lstrip("*") + if not text: + return False + marker_head = "LLM Running (Turn " + return marker_head.startswith(text) or text.startswith(marker_head) + +def _maybe_partial_code_fence(line): + return bool(re.match(r"^\s*`{1,}[^`\r\n]*$", line or "")) + +def _extract_turn_summary(raw_text): + search_text = _TURN_SUMMARY_SEARCH_STRIP_RE.sub("", raw_text or "") + match = _TURN_SUMMARY_RE.search(search_text) + if not match: + return "" + summary = re.sub(r"\s+", " ", match.group(1)).strip() + if len(summary) > _TURN_SUMMARY_LIMIT: + summary = summary[:_TURN_SUMMARY_LIMIT - 3].rstrip() + "..." + return summary + +def _quote_tag(text): + safe_text = (text or "").strip().replace(_QUOTE_OPEN_TAG, "").replace(_QUOTE_CLOSE_TAG, "") + return f"{_QUOTE_OPEN_TAG}{safe_text}{_QUOTE_CLOSE_TAG}" + +def _inject_turn_summary(body, summary): + if not (body or "").strip() or not (summary or "").strip(): + return body + lines = (body or "").splitlines() + if not lines or _turn_marker_number(lines[0]) is None: + return body + title = lines[0].strip() + rest = "\n".join(lines[1:]).strip() + summary_line = _quote_tag(summary) + if rest: + return f"{title}\n\n{summary_line}\n\n{rest}" + return f"{title}\n\n{summary_line}" + +def _resolve_files(paths): + files, seen = [], set() + for fpath in paths: + if not os.path.isabs(fpath): + fpath = os.path.join(_TEMP_DIR, fpath) + if fpath in seen or not os.path.exists(fpath): + continue + files.append(fpath) + seen.add(fpath) + return files + + +def _render_file_markers(text): + def repl(match): + return os.path.basename(match.group(1)) + return re.sub(r"\[FILE:([^\]]+)\]", repl, text or "").strip() + +def _files_from_text(text): + cleaned = clean_reply(text) if (text or "").strip() else "" + return _resolve_files(extract_files(cleaned)) + +async def _send_files(root_msg, files): + for fpath in files: + if fpath.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")): + try: + with open(fpath, "rb") as fp: + await root_msg.reply_photo(fp) + except Exception: + pass + else: + try: + with open(fpath, "rb") as fp: + await root_msg.reply_document(fp) + except Exception: + pass + +async def _send_files_from_text(root_msg, text): + await _send_files(root_msg, _files_from_text(text)) + +def _escape_pre(text): + return escape_markdown(text or "", version=2, entity_type="pre") + +def _escape_code(text): + return escape_markdown(text or "", version=2, entity_type="code") + +def _escape_link_target(text): + return escape_markdown(text or "", version=2, entity_type="text_link") + +def _quote_to_markdown_v2(text): + lines = (text or "").strip().splitlines() or [""] + return "\n".join(f"> {escape_markdown(line, version=2)}" for line in lines) + +def _to_markdown_v2(text): + if not text: + return "" + parts, pos = [], 0 + for match in _MD_TOKEN_RE.finditer(text): + parts.append(escape_markdown(text[pos:match.start()], version=2)) + if match.group(1): + lang = re.sub(r"[^A-Za-z0-9_+-]", "", match.group(2) or "") + code = _escape_pre(match.group(3) or "") + header = f"```{lang}\n" if lang else "```\n" + parts.append(f"{header}{code}\n```") + elif match.group(4) is not None: + parts.append(_quote_to_markdown_v2(match.group(4))) + elif match.group(5) is not None: + label = escape_markdown(match.group(5), version=2) + target = _escape_link_target(match.group(6)) + parts.append(f"[{label}]({target})") + elif match.group(7) is not None: + parts.append(f"`{_escape_code(match.group(7))}`") + elif match.group(8) is not None: + parts.append(f"*{escape_markdown(match.group(8), version=2)}*") + elif match.group(9) is not None: + parts.append(f"*{escape_markdown(match.group(9), version=2)}*") + elif match.group(10) is not None: + parts.append(f"~{escape_markdown(match.group(10), version=2)}~") + elif match.group(11) is not None: + parts.append(f"_{escape_markdown(match.group(11), version=2)}_") + pos = match.end() + parts.append(escape_markdown(text[pos:], version=2)) + return "".join(parts) + +def _is_not_modified_error(exc): + return "not modified" in str(exc).lower() + +def _extract_ask_user_event(ctx): + exit_reason = (ctx or {}).get("exit_reason") or {} + if exit_reason.get("result") != "EXITED": + return None + payload = exit_reason.get("data") + if not isinstance(payload, dict): + return None + if payload.get("status") != "INTERRUPT" or payload.get("intent") != "HUMAN_INTERVENTION": + return None + data = payload.get("data") + if not isinstance(data, dict): + return None + raw_candidates = data.get("candidates") or [] + if not isinstance(raw_candidates, (list, tuple)): + return None + candidates = [] + for candidate in raw_candidates: + if candidate is None: + continue + text = str(candidate).strip() + if text: + candidates.append(text) + if not candidates: + return None + question = str(data.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:" + return { + "question": question, + "candidates": candidates, + "multi": bool(_MULTI_SELECT_RE.search(question)), + } + +def _register_ask_user_hook(): + if not hasattr(agent, "_turn_end_hooks"): + agent._turn_end_hooks = {} + def _hook(ctx): + event = _extract_ask_user_event(ctx) + if event: + _ask_menu_events.put(event) + agent._turn_end_hooks[_ASK_USER_HOOK_KEY] = _hook + +def _drain_latest_ask_user_event(): + latest = None + while True: + try: + latest = _ask_menu_events.get_nowait() + except Q.Empty: + break + return latest + +def _build_ask_user_markup(menu_id, candidates, multi=False, selected_indexes=None): + selected_indexes = set(selected_indexes or []) + rows = [] + for idx, candidate in enumerate(candidates): + if multi: + label = f"✓ {candidate}" if idx in selected_indexes else candidate + action = f"{_ASK_TOGGLE_ACTION}:{idx}" + else: + label = candidate + action = str(idx) + rows.append([ + InlineKeyboardButton(label, callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{action}") + ]) + if multi: + rows.append([ + InlineKeyboardButton("Done", callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{_ASK_MULTI_DONE_ACTION}") + ]) + rows.append([ + InlineKeyboardButton(_ASK_CANCEL_LABEL, callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{_ASK_CANCEL_ACTION}") + ]) + return InlineKeyboardMarkup(rows) + +def _build_llm_markup(menu_id, llms): + rows = [] + for idx, name, current in llms: + label = f"→ [{idx}] {name}" if current else f"[{idx}] {name}" + rows.append([ + InlineKeyboardButton(label, callback_data=f"{_LLM_CALLBACK_PREFIX}{menu_id}:{idx}") + ]) + return InlineKeyboardMarkup(rows) + +def _parse_menu_callback_data(data, prefix): + if not (data or "").startswith(prefix): + return None, None + payload = data[len(prefix):] + menu_id, sep, action = payload.partition(":") + if not sep or not menu_id or not action: + return None, None + return menu_id, action + +def _parse_ask_callback_data(data): + return _parse_menu_callback_data(data, _ASK_CALLBACK_PREFIX) + +def _build_text_prompt(text): + return f"{FILE_HINT}\n\n{text}" + +def _normalize_ask_menu_event(stored): + if isinstance(stored, dict): + candidates = stored.get("candidates") or [] + return { + "question": str(stored.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:", + "candidates": [str(candidate).strip() for candidate in candidates if str(candidate).strip()], + "multi": bool(stored.get("multi")), + "selected": [int(idx) for idx in stored.get("selected", []) if isinstance(idx, int)], + } + if isinstance(stored, (list, tuple)): + return { + "question": "请选择下一步操作:", + "candidates": [str(candidate).strip() for candidate in stored if str(candidate).strip()], + "multi": False, + "selected": [], + } + return None + +def _render_ask_user_result(event, selected=None, cancelled=False): + question = str(event.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:" + candidates = event.get("candidates") or [] + lines = [question, "", "选项:"] + for idx, candidate in enumerate(candidates, start=1): + lines.append(f"{idx}. {candidate}") + lines.append(f"{len(candidates) + 1}. {_ASK_CANCEL_LABEL}") + lines.append("") + if cancelled: + lines.append(f"已取消:{_ASK_CANCEL_LABEL}") + elif selected: + lines.append(f"已选择:{selected}") + text = "\n".join(lines) + if len(text) > MessageLimit.MAX_TEXT_LENGTH: + text = text[:MessageLimit.MAX_TEXT_LENGTH - 18].rstrip() + "\n...[truncated]" + return text + +async def _clear_ask_reply_markup(query): + try: + await query.edit_message_reply_markup(reply_markup=None) + except Exception as exc: + print(f"[TG ask_user menu cleanup] {type(exc).__name__}: {exc}", flush=True) + +async def _edit_ask_user_result(query, event, selected=None, cancelled=False): + try: + await query.edit_message_text( + _render_ask_user_result(event, selected=selected, cancelled=cancelled), + reply_markup=None, + ) + except Exception as exc: + print(f"[TG ask_user menu edit] {type(exc).__name__}: {exc}", flush=True) + await _clear_ask_reply_markup(query) + +async def _send_ask_user_menu(root_msg, event): + menu_id = uuid.uuid4().hex[:16] + candidates = event["candidates"] + multi = bool(event.get("multi")) + _ask_menu_store[menu_id] = { + "question": event["question"], + "candidates": list(candidates), + "multi": multi, + "selected": [], + } + prompt = f"{event['question']}\n\n{_ASK_MULTI_HINT}" if multi else event["question"] + try: + await root_msg.reply_text( + prompt, + reply_markup=_build_ask_user_markup(menu_id, candidates, multi=multi), + ) + except Exception as exc: + _ask_menu_store.pop(menu_id, None) + print(f"[TG ask_user menu error] {type(exc).__name__}: {exc}", flush=True) + fallback = event["question"] + "\n" + "\n".join(f"- {candidate}" for candidate in candidates) + await root_msg.reply_text(fallback) + +class _TelegramStreamSession: + def __init__(self, root_msg): + self.root_msg = root_msg + self.private_chat = getattr(getattr(root_msg, "chat", None), "type", "") == ChatType.PRIVATE + self.can_use_draft = self.private_chat # update tg client! + self.draft_id = _make_draft_id() + self.live_msg = None + self.raw_text = "" + self.files = [] + self.sent_segments = 0 + self.active_display = "" + self.pending_display = "" + self._edit_overflow_msgs = {} + self.retry_until = 0.0 + self.last_update_at = 0.0 + self.last_update_raw_len = 0 + + def _now(self): + return time.monotonic() + + def _retry_after_seconds(self, exc): + retry_after = getattr(exc, "_retry_after", None) + if retry_after is None: + retry_after = getattr(exc, "retry_after", 0) or 0 + if hasattr(retry_after, "total_seconds"): + retry_after = retry_after.total_seconds() + try: + return max(0.0, float(retry_after)) + except (TypeError, ValueError): + return 0.0 + + def _set_retry_after(self, exc): + wait_seconds = self._retry_after_seconds(exc) + _RETRY_AFTER_MARGIN_SECONDS + self.retry_until = max(self.retry_until, self._now() + wait_seconds) + + def _is_retrying(self): + return self._now() < self.retry_until + + async def _wait_for_retry(self): + remaining = self.retry_until - self._now() + if remaining > 0: + await asyncio.sleep(remaining) + + def _should_stream_update(self, display): + if display == self.active_display: + return False + if self.last_update_at <= 0: + return True + elapsed = self._now() - self.last_update_at + raw_delta = len(self.raw_text) - self.last_update_raw_len + return elapsed >= _STREAM_UPDATE_INTERVAL_SECONDS or raw_delta >= _STREAM_MIN_UPDATE_CHARS + + def _mark_stream_update(self, display): + self.active_display = display + self.pending_display = "" + self.last_update_at = self._now() + self.last_update_raw_len = len(self.raw_text) + + def _stream_display(self, text): + base = (text or _DRAFT_HINT).strip() or _DRAFT_HINT + safe_parts = _markdown_safe_segments(base) + base = safe_parts[-1] if safe_parts else _DRAFT_HINT + if base == _DRAFT_HINT: + return base + display = base + _STREAM_SUFFIX + if len(_to_markdown_v2(display)) <= MessageLimit.MAX_TEXT_LENGTH: + return display + return base + + async def prime(self): + if self.can_use_draft: + draft_result = await self._send_draft(_DRAFT_HINT) + if draft_result is True: + self.active_display = _DRAFT_HINT + return + if draft_result is None: + self.active_display = _DRAFT_HINT + return + try: + await self._upsert_live_message(_DRAFT_HINT, wait_retry=False) + except RetryAfter: + self.active_display = _DRAFT_HINT + return + self.active_display = _DRAFT_HINT + + async def add_chunk(self, chunk): + if not chunk: + return + self.raw_text += chunk + await self._refresh(done=False, send_files=False) + + async def finalize(self, full_text=None, send_files=True): + if full_text is not None: + self.raw_text = full_text + await self._refresh(done=True, send_files=send_files) + + async def finish_with_notice(self, notice): + if self.raw_text.strip(): + await self.finalize(send_files=False) + await self._reply_text(notice) + return + if self.live_msg is not None: + await self._edit_text(self.live_msg, notice) + self.live_msg = None + self.active_display = "" + return + await self._reply_text(notice) + self.active_display = "" + + async def _refresh(self, done, send_files): + summary = _extract_turn_summary(self.raw_text) + cleaned = clean_reply(self.raw_text) if self.raw_text.strip() else "" + self.files = _files_from_text(cleaned) + body = _inject_turn_summary(_render_file_markers(cleaned), summary) + if done and not body and self.files: + body = "已生成附件" + elif done and not body: + body = "..." + segments = _visible_segments(body) + finalized_target = len(segments) if done else max(len(segments) - 1, 0) + while self.sent_segments < finalized_target: + await self._finalize_segment(segments[self.sent_segments]) + self.sent_segments += 1 + if done: + if send_files: + await self._send_files() + return + active_text = segments[-1] if segments else _DRAFT_HINT + await self._stream_active(active_text) + + async def _stream_active(self, text): + display = self._stream_display(text) + if display == self.active_display: + return + self.pending_display = display + if self._is_retrying() or not self._should_stream_update(display): + return + try: + if self.can_use_draft: + draft_result = await self._send_draft(display) + if draft_result is True: + self._mark_stream_update(display) + return + if draft_result is None: + return + await self._upsert_live_message(display, wait_retry=False) + self._mark_stream_update(display) + except RetryAfter: + return + + async def _finalize_segment(self, text): + final_text = (text or "").strip() or "..." + if self.live_msg is not None: + await self._edit_text(self.live_msg, final_text) + self.live_msg = None + else: + await self._reply_text(final_text) + self.active_display = "" + if self.can_use_draft: + self.draft_id = _make_draft_id() + + async def _send_files(self): + await _send_files(self.root_msg, self.files) + + async def _send_draft(self, text): + try: + await self.root_msg.reply_text_draft( + self.draft_id, + _to_markdown_v2(text), + parse_mode=ParseMode.MARKDOWN_V2, + ) + return True + except RetryAfter as exc: + self._set_retry_after(exc) + return None + except Exception as exc: + if _is_not_modified_error(exc): + return True + print(f"[TG draft fallback] {type(exc).__name__}: {exc}", flush=True) + self.can_use_draft = False + self.draft_id = _make_draft_id() + return False + + async def _retry_call(self, func, *args): + while True: + await self._wait_for_retry() + try: + return await func(*args) + except RetryAfter as exc: + self._set_retry_after(exc) + + async def _reply_text_once(self, text): + markdown = _to_markdown_v2(text) + try: + return await self.root_msg.reply_text(markdown, parse_mode=ParseMode.MARKDOWN_V2) + except RetryAfter as exc: + self._set_retry_after(exc) + raise + except Exception as exc: + if _is_not_modified_error(exc): + return None + try: + return await self.root_msg.reply_text(text) + except RetryAfter as retry_exc: + self._set_retry_after(retry_exc) + raise + + async def _reply_text(self, text, wait_retry=True): + last_msg = None + for segment in _markdown_safe_segments(text) or ["..."]: + if wait_retry: + last_msg = await self._retry_call(self._reply_text_once, segment) + else: + last_msg = await self._reply_text_once(segment) + return last_msg + + async def _edit_text_once(self, msg, text): + markdown = _to_markdown_v2(text) + try: + updated = await msg.edit_text(markdown, parse_mode=ParseMode.MARKDOWN_V2) + except RetryAfter as exc: + self._set_retry_after(exc) + raise + except Exception as exc: + if _is_not_modified_error(exc): + return msg + try: + updated = await msg.edit_text(text) + except RetryAfter as retry_exc: + self._set_retry_after(retry_exc) + raise + return updated if hasattr(updated, "edit_text") else msg + + def _message_key(self, msg): + chat_id = getattr(getattr(msg, "chat", None), "id", None) + message_id = getattr(msg, "message_id", None) + if chat_id is not None and message_id is not None: + return (chat_id, message_id) + if message_id is not None: + return ("message", message_id) + return ("object", id(msg)) + + async def _delete_text_once(self, msg): + delete = getattr(msg, "delete", None) + if delete is None: + return + try: + result = delete() + if hasattr(result, "__await__"): + await result + except RetryAfter as exc: + self._set_retry_after(exc) + raise + except Exception as exc: + print(f"[TG stale overflow delete error] {type(exc).__name__}: {exc}", flush=True) + + async def _delete_text(self, msg, wait_retry=True): + if wait_retry: + await self._retry_call(self._delete_text_once, msg) + else: + await self._delete_text_once(msg) + + async def _edit_text(self, msg, text, wait_retry=True): + segments = _markdown_safe_segments(text) or ["..."] + old_key = self._message_key(msg) + overflow_msgs = self._edit_overflow_msgs.get(old_key, []) + if wait_retry: + updated = await self._retry_call(self._edit_text_once, msg, segments[0]) + else: + updated = await self._edit_text_once(msg, segments[0]) + primary_msg = updated if hasattr(updated, "edit_text") else msg + self._edit_overflow_msgs.pop(old_key, None) + + new_overflow_msgs = [] + for index, segment in enumerate(segments[1:]): + if index < len(overflow_msgs): + overflow_msg = overflow_msgs[index] + if wait_retry: + edited_overflow = await self._retry_call(self._edit_text_once, overflow_msg, segment) + else: + edited_overflow = await self._edit_text_once(overflow_msg, segment) + new_overflow_msgs.append( + edited_overflow if hasattr(edited_overflow, "edit_text") else overflow_msg + ) + else: + new_overflow_msgs.append(await self._reply_text(segment, wait_retry=wait_retry)) + + for stale_msg in overflow_msgs[len(new_overflow_msgs):]: + await self._delete_text(stale_msg, wait_retry=wait_retry) + + if new_overflow_msgs: + self._edit_overflow_msgs[self._message_key(primary_msg)] = new_overflow_msgs + return primary_msg + + async def _upsert_live_message(self, text, wait_retry=True): + if self.live_msg is None: + self.live_msg = await self._reply_text(text, wait_retry=wait_retry) + else: + self.live_msg = await self._edit_text(self.live_msg, text, wait_retry=wait_retry) + + +class _TelegramTurnStreamCoordinator: + def __init__(self, root_msg): + self.root_msg = root_msg + self.session = None + self.pending_line = "" + self.code_fence_len = 0 + self.last_turn = 0 + + async def prime(self): + await self._ensure_session() + + async def add_chunk(self, chunk): + if not chunk: + return + text = self.pending_line + chunk + self.pending_line = "" + for line in text.splitlines(keepends=True): + if _line_complete(line): + await self._process_line(line) + elif _maybe_partial_turn_marker(line) or _maybe_partial_code_fence(line): + self.pending_line = line + else: + await self._process_line(line) + + async def finalize(self, done_text="", send_files=True): + await self._flush_pending_line() + if self.session is None: + if done_text: + await self._add_to_current(done_text) + elif not self.session.raw_text.strip() and done_text: + await self.session.finalize(done_text, send_files=False) + if send_files: + await _send_files_from_text(self.root_msg, done_text) + return + if self.session is not None: + await self.session.finalize(send_files=False) + if send_files: + await _send_files_from_text(self.root_msg, done_text) + + async def finish_with_notice(self, notice): + await self._flush_pending_line() + await self._ensure_session() + await self.session.finish_with_notice(notice) + + async def _ensure_session(self): + if self.session is None: + self.session = _TelegramStreamSession(self.root_msg) + await self.session.prime() + + async def _start_turn(self, marker): + if self.session is not None and self.session.raw_text.strip(): + await self.session.finalize(send_files=False) + self.session = None + await self._ensure_session() + await self.session.add_chunk(marker) + + async def _add_to_current(self, text): + if not text: + return + await self._ensure_session() + await self.session.add_chunk(text) + + async def _process_line(self, line): + turn_no = _turn_marker_number(line) + if self.code_fence_len == 0 and turn_no == self.last_turn + 1: + self.last_turn = turn_no + await self._start_turn(line) + return + await self._add_to_current(line) + self._update_code_fence(line) + + async def _flush_pending_line(self): + if not self.pending_line: + return + line = self.pending_line + self.pending_line = "" + await self._add_to_current(line) + + def _update_code_fence(self, line): + match = _CODE_FENCE_RE.match(line or "") + if not match: + return + fence_len = len(match.group(1)) + if self.code_fence_len: + if fence_len >= self.code_fence_len: + self.code_fence_len = 0 + return + self.code_fence_len = fence_len + +async def _stream(dq, msg): + stream = _TelegramTurnStreamCoordinator(msg) + await stream.prime() + try: + while True: + try: first = await asyncio.to_thread(dq.get, True, _QUEUE_WAIT_SECONDS) + except Q.Empty: continue + items = [first] + try: + while True: items.append(dq.get_nowait()) + except Q.Empty: pass + done_item = None + for item in items: + chunk = item.get("next", "") + if chunk: + await stream.add_chunk(chunk) + if "done" in item: + done_item = item + break + if done_item is not None: + await stream.finalize(done_item.get("done", "")) + event = _drain_latest_ask_user_event() + if event: + await _send_ask_user_menu(msg, event) + break + except asyncio.CancelledError: + await stream.finish_with_notice("⏹️ 已停止") + except RetryAfter as exc: + print(f"[TG stream retry_after] {type(exc).__name__}: {exc}", flush=True) + if stream.session is not None: + stream.session._set_retry_after(exc) + except Exception as exc: + print(f"[TG stream error] {type(exc).__name__}: {exc}", flush=True) + if stream.session is not None and stream.session._is_retrying(): + return + try: + await stream.finish_with_notice(f"❌ 输出失败: {exc}") + except RetryAfter as retry_exc: + print(f"[TG stream error notice retry_after] {type(retry_exc).__name__}: {retry_exc}", flush=True) + +def _normalized_command(text): + parts = (text or "").strip().split(None, 1) + if not parts: return '' + head = parts[0].lower() + if head.startswith('/'): head = '/' + head[1:].split('@', 1)[0] + return head + (f" {parts[1].strip()}" if len(parts) > 1 and parts[1].strip() else '') + +def _cancel_stream_task(ctx): + task = ctx.user_data.pop('stream_task', None) + if task and not task.done(): task.cancel() + +async def _sync_commands(application): + await application.bot.set_my_commands([BotCommand(command, description) for command, description in TELEGRAM_MENU_COMMANDS]) + +async def _reply_command_text(message, text): + for segment in _markdown_safe_segments(text) or ["..."]: + try: + await message.reply_text(_to_markdown_v2(segment), parse_mode=ParseMode.MARKDOWN_V2) + except Exception as exc: + print(f"[TG command markdown fallback] {type(exc).__name__}: {exc}", flush=True) + await message.reply_text(segment) + +def _review_command_body(cmd): + cmd = (cmd or "").strip() + if cmd == "/review": + return "" + if cmd.startswith("/review "): + return cmd[len("/review"):].strip() + return "" + +async def _handle_review_command(update, ctx, cmd): + dq = Q.Queue() + prompt = handle_review_command(agent, _review_command_body(cmd), dq) + if not prompt: + try: + item = dq.get_nowait() + return await _reply_command_text(update.message, item.get("done", "")) + except Q.Empty: + return await _reply_command_text(update.message, "(review 无输出)") + _cancel_stream_task(ctx) + task_dq = agent.put_task(prompt, source="telegram") + task = asyncio.create_task(_stream(task_dq, update.message)) + ctx.user_data['stream_task'] = task + +async def handle_msg(update, ctx): + uid = update.effective_user.id + if ALLOWED and uid not in ALLOWED: + return await update.message.reply_text("no") + prompt = _build_text_prompt(update.message.text) + dq = agent.put_task(prompt, source="telegram") + task = asyncio.create_task(_stream(dq, update.message)) + ctx.user_data['stream_task'] = task + +async def handle_ask_callback(update, ctx): + query = update.callback_query + if query is None: + return + uid = update.effective_user.id if update.effective_user else None + if ALLOWED and uid not in ALLOWED: + return await query.answer("no", show_alert=True) + menu_id, action = _parse_ask_callback_data(query.data) + if not menu_id: + return await query.answer("菜单无效") + event = _normalize_ask_menu_event(_ask_menu_store.get(menu_id)) + if event is None: + await query.answer("菜单已过期") + return await _clear_ask_reply_markup(query) + candidates = event["candidates"] + if event.get("multi") and action.startswith(f"{_ASK_TOGGLE_ACTION}:"): + try: + selected_idx = int(action.split(":", 1)[1]) + if selected_idx < 0 or selected_idx >= len(candidates): + raise ValueError + except ValueError: + return await query.answer("菜单无效") + stored = _ask_menu_store.get(menu_id) + if not isinstance(stored, dict): + return await query.answer("菜单已过期") + selected = set(stored.get("selected", [])) + if selected_idx in selected: + selected.remove(selected_idx) + else: + selected.add(selected_idx) + stored["selected"] = sorted(selected) + await query.answer() + return await query.edit_message_reply_markup( + reply_markup=_build_ask_user_markup( + menu_id, + candidates, + multi=True, + selected_indexes=stored["selected"], + ) + ) + if event.get("multi") and action == _ASK_MULTI_DONE_ACTION: + selected_indexes = event.get("selected") or [] + if not selected_indexes: + return await query.answer(_ASK_MULTI_EMPTY_HINT, show_alert=True) + selected = "; ".join(candidates[idx] for idx in selected_indexes) + _ask_menu_store.pop(menu_id, None) + await query.answer() + await _edit_ask_user_result(query, event, selected=selected) + if query.message is None: + return + dq = agent.put_task(_build_text_prompt(selected), source="telegram") + task = asyncio.create_task(_stream(dq, query.message)) + ctx.user_data['stream_task'] = task + return + if action == _ASK_CANCEL_ACTION: + _ask_menu_store.pop(menu_id, None) + await query.answer() + await _edit_ask_user_result(query, event, cancelled=True) + if query.message is not None: + await query.message.reply_text(_ASK_CANCEL_PROMPT) + return + try: + selected = candidates[int(action)] + except (ValueError, IndexError): + return await query.answer("菜单无效") + _ask_menu_store.pop(menu_id, None) + await query.answer() + await _edit_ask_user_result(query, event, selected=selected) + if query.message is None: + return + dq = agent.put_task(_build_text_prompt(selected), source="telegram") + task = asyncio.create_task(_stream(dq, query.message)) + ctx.user_data['stream_task'] = task + +async def _send_llm_menu(message): + llms = agent.list_llms() + if not llms: + return await message.reply_text("没有可用模型。") + menu_id = uuid.uuid4().hex[:16] + _llm_menu_store[menu_id] = [idx for idx, _, _ in llms] + lines = [f"{'→' if cur else ' '} [{idx}] {name}" for idx, name, cur in llms] + try: + await message.reply_text( + _LLM_MENU_PROMPT, + reply_markup=_build_llm_markup(menu_id, llms), + ) + except Exception as exc: + _llm_menu_store.pop(menu_id, None) + print(f"[TG llm menu error] {type(exc).__name__}: {exc}", flush=True) + await message.reply_text("LLMs:\n" + "\n".join(lines)) + +async def handle_llm_callback(update, ctx): + query = update.callback_query + if query is None: + return + uid = update.effective_user.id if update.effective_user else None + if ALLOWED and uid not in ALLOWED: + return await query.answer("no", show_alert=True) + menu_id, action = _parse_menu_callback_data(query.data, _LLM_CALLBACK_PREFIX) + if not menu_id: + return await query.answer("菜单无效") + valid_indexes = _llm_menu_store.get(menu_id) + if valid_indexes is None: + await query.answer("菜单已过期") + return await _clear_ask_reply_markup(query) + try: + selected_idx = int(action) + except (TypeError, ValueError): + return await query.answer("菜单无效") + if selected_idx not in valid_indexes: + return await query.answer("菜单已过期", show_alert=True) + try: + agent.next_llm(selected_idx) + selected_name = agent.get_llm_name() + except Exception as exc: + return await query.answer(f"切换失败: {exc}", show_alert=True) + _llm_menu_store.pop(menu_id, None) + await query.answer(f"已切换到 [{selected_idx}] {selected_name}") + await query.edit_message_text(f"✅ 已切换到 [{selected_idx}] {selected_name}") + +async def cmd_abort(update, ctx): + _cancel_stream_task(ctx) + agent.abort() + await update.message.reply_text("⏹️ 正在停止...") + +async def cmd_llm(update, ctx): + args = (update.message.text or '').split() + if len(args) > 1: + try: + n = int(args[1]) + agent.next_llm(n) + await update.message.reply_text(f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}") + except (ValueError, IndexError): + await update.message.reply_text(f"用法: /llm <0-{len(agent.list_llms())-1}>") + else: + await _send_llm_menu(update.message) + +async def handle_photo(update, ctx): + uid = update.effective_user.id + if ALLOWED and uid not in ALLOWED: return await update.message.reply_text("no") + if update.message.photo: + photo = update.message.photo[-1] + file = await photo.get_file() + fpath = f"tg_{photo.file_unique_id}.jpg" + kind = "图片" + elif update.message.document: + doc = update.message.document + file = await doc.get_file() + ext = os.path.splitext(doc.file_name or '')[1] or '' + fpath = f"tg_{doc.file_unique_id}{ext}" + kind = "文件" + else: return + await file.download_to_drive(os.path.join(_TEMP_DIR, fpath)) + caption = update.message.caption + prompt = f"[TIPS] 收到{kind}temp/{fpath}\n{caption}" if caption else f"[TIPS] 收到{kind}temp/{fpath},请等待下一步指令" + dq = agent.put_task(prompt, source="telegram") + task = asyncio.create_task(_stream(dq, update.message)) + ctx.user_data['stream_task'] = task + +async def handle_command(update, ctx): + uid = update.effective_user.id + if ALLOWED and uid not in ALLOWED: + return await update.message.reply_text("no") + cmd = _normalized_command(update.message.text) + op = cmd.split()[0] if cmd else '' + if op == '/help': return await update.message.reply_text(HELP_TEXT) + if op == '/status': + llm = agent.get_llm_name() if agent.llmclient else '未配置' + return await update.message.reply_text(f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}") + if op == '/stop': return await cmd_abort(update, ctx) + if op == '/llm': return await cmd_llm(update, ctx) + if op == '/btw': + answer = await asyncio.to_thread(handle_btw_frontend_command, agent, cmd) + return await _reply_command_text(update.message, answer) + if op == '/review': + return await _handle_review_command(update, ctx, cmd) + if op == '/new': + _cancel_stream_task(ctx) + return await update.message.reply_text(reset_conversation(agent)) + if op == '/restore': + _cancel_stream_task(ctx) + try: + restored_info, err = format_restore() + if err: + return await update.message.reply_text(err) + restored, fname, count = restored_info + agent.abort() + agent.history.extend(restored) + return await update.message.reply_text(f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)") + except Exception as e: + return await update.message.reply_text(f"❌ 恢复失败: {e}") + if op == '/continue': + if cmd != '/continue': _cancel_stream_task(ctx) + return await update.message.reply_text(handle_frontend_command(agent, cmd)) + return await update.message.reply_text(HELP_TEXT) + +if __name__ == '__main__': + _LOCK_SOCK = ensure_single_instance(19527, "Telegram") + if not ALLOWED: + print('[Telegram] ERROR: tg_allowed_users in mykey.py is empty or missing. Set it to avoid unauthorized access.') + sys.exit(1) + require_runtime(agent, "Telegram", tg_bot_token=mykeys.get("tg_bot_token")) + redirect_log(__file__, "tgapp.log", "Telegram", ALLOWED) + _register_ask_user_hook() + threading.Thread(target=agent.run, daemon=True).start() + proxy = mykeys.get('proxy') + if proxy: + print('proxy:', proxy) + else: + print('proxy: ') + + async def _error_handler(update, context: ContextTypes.DEFAULT_TYPE): + print(f"[{time.strftime('%m-%d %H:%M')}] TG error: {context.error}", flush=True) + + while True: + try: + print(f"TG bot starting... {time.strftime('%m-%d %H:%M')}") + # Recreate request and app objects on each restart to avoid stale connections + request_kwargs = dict(read_timeout=30, write_timeout=30, connect_timeout=30, pool_timeout=30) + if proxy: + request_kwargs['proxy'] = proxy + request = HTTPXRequest(**request_kwargs) + app = (ApplicationBuilder().token(mykeys['tg_bot_token']) + .request(request).get_updates_request(request).post_init(_sync_commands).build()) + app.add_handler(CallbackQueryHandler(handle_ask_callback, pattern=r"^ask:")) + app.add_handler(CallbackQueryHandler(handle_llm_callback, pattern=r"^llm:")) + app.add_handler(MessageHandler(filters.COMMAND, handle_command)) + app.add_handler(MessageHandler(filters.PHOTO, handle_photo)) + app.add_handler(MessageHandler(filters.Document.ALL, handle_photo)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_msg)) + app.add_error_handler(_error_handler) + app.run_polling(drop_pending_updates=True, poll_interval=1.0, timeout=30) + except Exception as e: + print(f"[{time.strftime('%m-%d %H:%M')}] polling crashed: {e}", flush=True) + time.sleep(10) + asyncio.set_event_loop(asyncio.new_event_loop()) diff --git a/frontends/tui_v3.py b/frontends/tui_v3.py new file mode 100644 index 0000000..0684d53 --- /dev/null +++ b/frontends/tui_v3.py @@ -0,0 +1,6050 @@ +"""tui_v3 — scrollback-first TUI for GenericAgent, consolidated. + +Merged from frontends/tui/ (cjk, clipboard, renderer, protocol, core/sb) +into a single file so the v3 frontend ships as one drop-in module. +Run: `python -m frontends.tui_v3` or `python frontends/tui_v3.py`. +""" +from __future__ import annotations + +import asyncio, atexit, json, locale, logging, os, queue, random, re, select, shutil, signal, subprocess +import sys, tempfile, threading, time + +_IS_WINDOWS = os.name == 'nt' + + +# Make `frontends/` parent (project root) importable so `from agentmain import …` +# works whether this file is run as `python -m frontends.tui_v3` or directly +# via `python frontends/tui_v3.py`. +_proj_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_front_dir = os.path.dirname(os.path.abspath(__file__)) +for _p in (_proj_root, _front_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +from agentmain import GeneraticAgent +from dataclasses import dataclass +from dataclasses import dataclass, field +from functools import lru_cache +from io import StringIO +from rich.cells import cell_len +from rich.console import Console +from rich.markdown import Markdown +from rich.text import Text +from rich.theme import Theme +from typing import Callable +from frontends import at_complete, workspace_cmd # @ 补全 + /workspace(与 v2 共用) + +# ════════════════════════════════════════════════════════════════════════════ +# i18n — minimal dict-based zh/en translation layer (inlined; was tui_v3_i18n.py) +# +# - Single nested dict `_I18N[lang][key]` → format string. +# - `t(key, **fmt)` returns the formatted string for the current language; +# falls back to English, then to the key itself (so missing keys are visible). +# - Language detection: persisted user choice > system locale > 'en'. +# - Persistence: temp/tui_v3_settings.json (workspace-local, matches v2 pattern). +# +# Strings that intentionally stay single-language (English): +# - Spinner gerunds (Pondering/Brewing/...) — ported from v2. +# - Tech jargon embedded in zh strings: 'tokens', 'ctx', 'model', 'session', … +# ════════════════════════════════════════════════════════════════════════════ + +_SETTINGS_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "temp", "tui_v3_settings.json" +) + +_SUPPORTED = ('zh', 'en') +_DEFAULT = 'en' + +_LANG: str = _DEFAULT + + +# ---------------- spinner gerunds (English only, from v2) ---------------- + +SPINNER_GERUNDS = ( + "Pondering", "Reticulating", "Sleuthing", "Hatching", "Pouncing", + "Brewing", "Sharpening", "Untangling", "Compiling", "Unraveling", + "Distilling", "Calibrating", "Marinating", "Conjuring", "Foraging", + "Spelunking", "Synthesizing", "Refactoring thoughts", "Tracing breadcrumbs", + "Following the rabbit hole", + "Routing", "Threading", "Polling", "Spinning", "Hooking", + "Patching", "Caching", "Yielding", "Hydrating", "Folding", + "Streaming", "Resolving", "Reaping", "Tuning", +) + + +# Language display names (always shown in their own script — never translated, +# so users always see them in a form they recognize). +LANG_LABELS = { + 'zh': '简体中文', + 'en': 'English', +} + + +# Rotating usage tips — one picked per launch, shown in the banner (v2 _TIPS). +# Only covers features v3 has actually adapted; the en/zh lists run parallel. +_TIPS = { + 'en': [ + "Tip: press / to open the command palette — arrow keys to pick, Enter drops it into the input box.", + "Tip: pasted images / files fold into [Image #N] / [File #N] placeholders; backspace deletes the whole block.", + "Tip: /btw lets a side-agent answer without interrupting the main task.", + "Tip: /rewind [n] rewinds the last n turns; /stop aborts the current task.", + "Tip: /continue lists past sessions — arrow keys to pick, Enter to restore.", + "Tip: Ctrl+J / Shift+Enter inserts a newline in multi-line input; Enter sends.", + "Tip: put [multi-select] in an ask_user prompt to switch to a multi-pick picker.", + "Tip: /cost shows token usage; /llm views / switches the model.", + "Tip: /new [name] starts a fresh session; /language switches the interface language.", + "Tip: /export clip copies the last reply to your system clipboard; /export all prints the log path.", + "Tip: Ctrl+O folds / unfolds all completed tool chips — each fold collapses to one line.", + "Tip: /update auto-runs git pull and audits the impact; /autorun seeds an autonomous run.", + "Tip: /morphling absorbs an external skill.", + "Tip: /goal enters Goal mode (will ask for budget / worker cap); /hive for multi-worker.", + "Tip: /conductor hands the task to frontends/conductor.py for multi-subagent orchestration.", + "Tip: /update runs a dual-branch upstream sync — previews the diff before fast-forwarding either side.", + "Tip: /scheduler is live — untick a running row to stop it; tick again to relaunch.", + "Tip: Ctrl+S stashes your draft input — it's waiting for you next time you open a picker.", + "Tip: /scheduler lists reflect tasks and starts them via `/scheduler start a,b,c`.", + "Tip: prefix `!` runs the rest as a host shell command — output is folded into LLM history.", + "Tip: /resume lists recent sessions you can pick from to restore prior context.", + ], + 'zh': [ + "Tip: 按 / 唤起命令面板 —— 方向键选择,Enter 落入输入框。", + "Tip: 粘贴图片 / 文件会折叠成 [Image #N] / [File #N] 占位符,退格可整块删除。", + "Tip: /btw <问题> 让 side-agent 回答而不打断主任务。", + "Tip: /rewind [n] 回退最近 n 轮对话;/stop 中止当前任务。", + "Tip: /continue 列出历史会话 —— 方向键选择,Enter 恢复。", + "Tip: 多行输入用 Ctrl+J / Shift+Enter 换行;Enter 直接发送。", + "Tip: ask_user 题目里写 [多选] 会自动切到多选 picker。", + "Tip: /cost 查看 token 用量;/llm 查看 / 切换模型。", + "Tip: /new [name] 新建会话;/language 切换界面语言。", + "Tip: /export clip 把最后回复复制到系统剪贴板;/export all 打印日志路径。", + "Tip: Ctrl+O 折叠 / 展开所有已完成的工具 chip —— 每个 chip 折叠成一行。", + "Tip: /conductor <任务> 直接交给 frontends/conductor.py 做多 subagent 编排。", + "Tip: /update 是双分支 upstream 同步 —— 先 diff 预演,再分别快进。", + "Tip: /scheduler 里再点一下已勾选的任务可以 stop —— 取消勾选 = 停止。", + "Tip: Ctrl+S 把当前输入 stash 起来,下次 / 打开 picker 时还在。", + "Tip: 以 `!` 开头直接跑 shell —— 命令与输出都会进入 LLM 历史,agent 可以引用。", + "Tip: /resume 列出最近会话,可挑选一个恢复之前的上下文。", + ], +} + + +# ---------------- translations ---------------- +# Keys are dot-namespaced. Format placeholders use {name}. + +_I18N: dict[str, dict[str, str]] = { + 'en': { + # /help intro & rows — phrasing mirrors v2's COMMANDS table. + 'help.title': 'Commands:', + 'help.help': ' /help Show help', + 'help.status': ' /status View session status', + 'help.sessions': ' /sessions List all sessions', + 'help.llm': ' /llm [n] View / switch model', + 'help.btw': ' /btw Side question — does not interrupt main agent', + 'help.review': ' /review [request] In-session code review (report inline)', + 'help.rewind': ' /rewind [n] Rewind the last n rounds', + 'help.continue': ' /continue [n|name] List / restore historical sessions', + 'help.new': ' /new [name] Start a new session (clears the current one)', + 'help.rename': ' /rename Rename the current session', + 'help.clear': ' /clear Clear display (does not touch LLM history)', + 'help.cost': ' /cost Token usage for the current session', + 'help.verbose': ' /verbose Tool-call audit (↑↓ select · Enter switch · c copy · q quit)', + 'help.export': ' /export [sub] Export last reply: clip / file [name] / all', + 'help.stop': ' /stop Abort current task', + 'help.language': ' /language [code] View / switch interface language', + 'help.update': ' /update [note] Preview upstream commits & diff, then pull (no commit)', + 'help.autorun': ' /autorun [seed] Enter autonomous-operation mode', + 'help.morphling': ' /morphling [target] Distill / absorb an external skill', + 'help.goal': ' /goal [goal] Enter Goal mode (asks for budget / worker cap)', + 'help.hive': ' /hive [target] Enter Hive multi-worker mode', + 'help.conductor': ' /conductor [task] Hand task to conductor.py for multi-subagent run', + 'help.scheduler': ' /scheduler Multi-pick reflect tasks / view cron', + 'help.emoji': ' /emoji [style] Pick the spinner pet face (picker / direct switch)', + 'help.quit': ' /quit Quit', + 'help.esc': ' Esc Cancel ask · clear draft · stop task (no exit)', + 'help.cc': ' Ctrl+C × 2 Quit (when idle; only aborts the task while running)', + 'help.cl': ' Ctrl+L Force repaint (recover from sleep/wake)', + 'help.cz': ' Ctrl+Z / Ctrl+Y Undo / redo input edits', + 'help.shift_arrow': ' Shift+←→↑↓ Select text (Ctrl+C copy / Ctrl+X cut / Ctrl+A all)', + + # _CMDS palette entries — same wording as /help, condensed for one-line hint. + 'cmd.help.desc': 'Show help', + 'cmd.status.desc': 'View session status', + 'cmd.llm.desc': 'View / switch model', + 'cmd.btw.desc': 'Side question — does not interrupt main agent', + 'cmd.review.desc': 'In-session code review', + 'cmd.rewind.desc': 'Rewind the last n rounds', + 'cmd.continue.desc': 'List / restore historical sessions', + 'cmd.new.desc': 'Start a new session', + 'cmd.rename.desc': 'Rename the current session', + 'cmd.clear.desc': 'Clear display (LLM history untouched)', + 'cmd.cost.desc': 'Token usage for the current session', + 'cmd.verbose.desc': 'Tool-call audit', + 'cmd.export.desc': 'Export the last reply (clip/file/all)', + 'cmd.stop.desc': 'Abort current task', + 'cmd.language.desc': 'View / switch interface language', + 'cmd.quit.desc': 'Quit', + + # _CMDS arg hints — mirror v2 (lowercase n, full word "question"). + 'cmd.llm.arg': '[n]', + 'cmd.btw.arg': '', + 'cmd.review.arg': '[request]', + 'cmd.rewind.arg': '[n]', + 'cmd.continue.arg': '[n|name]', + 'cmd.new.arg': '[name]', + 'cmd.rename.arg': '', + 'cmd.export.arg': '[clip|file|all]', + 'cmd.language.arg': '[code]', + 'cmd.update.arg': '[note]', + 'cmd.update.desc': 'preview upstream commits & diff, then pull (no commit)', + 'cmd.autorun.arg': '[seed]', + 'cmd.autorun.desc': 'enter autonomous operation mode', + 'cmd.morphling.arg': '[target]', + 'cmd.morphling.desc': 'distill / absorb external skills', + 'cmd.goal.arg': '[goal]', + 'cmd.goal.desc': 'enter Goal mode (needs condition)', + 'cmd.hive.arg': '[target]', + 'cmd.hive.desc': 'enter Hive multi-worker mode', + 'cmd.conductor.arg': '[task]', + 'cmd.conductor.desc': 'hand task to frontends/conductor.py for multi-subagent orchestration', + 'cmd.scheduler.desc': 'multi-pick start/stop reflect tasks (cron is driven by reflect/scheduler.py)', + 'cmd.emoji.arg': '[style]', + 'cmd.emoji.desc': 'pick the spinner pet face — opens picker; arg switches directly', + + # status line (one-liner above input box) + 'status.asking': '◉ waiting · Esc cancel', + 'status.running.tail': ' · Esc stop', + 'status.tps': ' · {rate:.0f} tok/s', + 'status.cc_confirm': 'Press Ctrl+C again to quit', + 'status.ready': '○ ready', + + # /status output rows + 'status.title': ' Session status', + 'status.label.model': 'model:', + 'status.label.state': 'state:', + 'status.label.rounds': 'rounds:', + 'status.label.context': 'context:', + 'status.label.cwd': 'cwd:', + 'status.state.running': 'running · {verb} {elapsed}', + 'status.state.waiting': 'waiting · ask_user pending', + 'status.state.idle': 'idle', + 'status.ctx.unknown': 'n/a', + 'status.ctx.fmt': '{used:,} / {cap:,} ctx', + + # banner + 'banner.label.model': 'model:', + 'banner.label.directory': 'directory:', + 'banner.label.session': 'session:', + 'banner.session.single': 'single · scrollback', + 'banner.llm_hint': '/llm switch', + + # messages — success / status + 'msg.ask_cancelled': '✗ ask cancelled · type freely or ask again', + 'msg.abort_requested': '⏹ abort requested · Esc', + 'msg.abort_done': '⏹ abort requested', + 'msg.idle_no_task': '(idle, no task)', + 'msg.cleared': 'new conversation · context cleared', + 'msg.new_session': 'new session · previous conversation cleared', + 'msg.new_session_named': 'new session "{name}" · previous conversation cleared', + 'msg.renamed': '✎ session renamed to "{name}"', + 'msg.rewind': '↩ rewound {n} turn(s) (removed {removed} history entries; scrollback is not editable)', + 'msg.no_rewindable': 'no rewindable turns', + 'msg.continue_loading': '┄┄ loaded {name}, full context above ┄┄', + 'msg.continue_ready': '┄┄ {msg} · continue typing ┄┄', + 'msg.llm_switched': 'LLM → {name}', + 'msg.export_done': 'exported: {path}', + 'msg.export_clipped': 'copied to clipboard ({n} chars)', + 'msg.export_clip_failed': '❌ copy failed: no clipboard tool found', + 'msg.export_all': 'full log:\n{path}', + 'msg.export_all_missing': 'no log file yet', + 'msg.review_empty': '(review produced no output)', + 'msg.no_export': '(no reply to export)', + 'msg.no_tracker': '(no stats yet)', + 'msg.no_history': ' no restorable historical sessions', + 'msg.no_llms': '(no LLMs available)', + 'msg.no_tools': ' (no tool-call records)', + 'msg.lang_current': 'Current language: {label} ({code})', + 'msg.lang_switched': 'Language → {label}', + 'msg.btw_no_answer': '(no answer)', + 'btw.title': 'side-questions · Esc to clear', + 'btw.querying': ' ⋯ querying…', + + # plan card + 'plan.header': 'Plan ({done}/{total})', + 'plan.complete': '✓ Plan complete ({n}/{n})', + 'plan.placeholder': 'Plan mode activated', + 'plan.waiting': 'waiting for {path} …', + 'plan.overflow': '+{n} more', + + # errors + 'err.running_blocked': 'busy — /stop before using this command', + 'err.continue_usage': 'usage: /continue or /continue N', + 'err.index_oob': '❌ index out of range (valid: 1-{max})', + 'err.btw_usage': 'usage: /btw (does not pollute main context)', + 'err.rewind_usage': 'usage: /rewind ', + 'err.rename_usage': 'usage: /rename ', + 'err.rewind_range': '❌ rewind failed: n must be 1-{max}', + 'err.lang_usage': 'usage: /language [code] (codes: {available})', + 'err.lang_unknown': 'unknown language code: {code} (available: {available})', + 'err.unknown_cmd': 'unknown command /{name} — /help to list', + 'err.multi_session': '/{name}: multi-session backend not wired yet; command reserved', + 'err.menu_cb': '❌ menu callback failed: {err}', + 'err.no_llm': 'No LLM configured — check mykey.py', + 'err.no_tty': 'tui_v3: needs a real TTY (run it in iTerm directly)', + 'err.dep_missing': 'Error: {name} is not installed.', + 'err.dep_install': 'Install with: pip install rich prompt_toolkit', + + # menu / picker / palette + 'menu.default_title': 'Pick', + 'menu.hint': '↑↓ pick · Enter confirm · Esc cancel', + 'menu.hint.filter': 'type to filter · ↑↓ pick · Enter confirm · Esc cancel', + 'menu.search': 'filter workspaces, or type an abs path + Enter to create one', + 'menu.no_match': 'no match', + 'menu.free.hint': 'Enter to create/enter this path', + 'ask.default_q': 'answer:', + 'ask.title': '◉ answer', + 'ask.pending': ' +{n} pending', + 'ask.hint.multi': '↳ ↑↓ move · Space toggle · Enter submit · Esc cancel', + 'ask.hint.single': '↳ ↑↓ navigate (options ⇄ input) · Enter confirm · Esc cancel', + 'ask.hint.freetext': '↳ ↑↓ back to options · type to input · Enter submit · Esc cancel', + + # continue picker + 'continue.title': 'Restore historical session', + 'continue.occupied.title': 'Session in use (pid {p}) — copy it and continue?', + 'continue.occupied.copy': 'Copy & continue', + 'continue.occupied.cancel': 'Cancel', + # /workspace (parity with v2; backed by workspace_cmd.py) + 'cmd.workspace.arg': '[path|off]', + 'cmd.workspace.desc': 'set working dir (abs path) and enter project mode', + 'ws.entered': '✅ entered workspace「{n}」', + 'ws.fail': '❌ workspace failed: {e}', + 'ws.exited': 'left workspace (project mode off; junction & files kept)', + 'ws.inactive': 'not in a workspace right now', + 'ws.none': 'no registered workspace yet; /workspace to create/enter', + 'ws.pick.title': 'Pick a workspace (↑↓ choose · Enter confirm · Esc cancel)', + 'ws.restored': 'restored working dir: {t}', + 'continue.row.fmt': '{rel:>4} {rounds:>3}r {preview}', + 'continue.unit.round': 'r', + + # llm picker + 'llm.title': 'Switch LLM', + + # emoji picker (pet style) + 'emoji.title': 'Pick spinner pet style', + 'emoji.switched': 'pet style → `{style}`', + 'emoji.unknown': 'unknown style `{choice}` — valid: {valid}', + 'emoji.row.current': '● {name:<8} {sample}', + 'emoji.row.other': ' {name:<8} {sample}', + 'emoji.row.off': '(hide pet)', + + # /scheduler picker (multi-pick reflect tasks / frontends) + 'scheduler.pick.title': 'Pick services — checked = running (untick to stop)', + 'scheduler.pick.hint': 'Space toggle · ↑↓ move · Enter next · Esc cancel · or /scheduler start a,b,c', + 'scheduler.cron.active': 'cron: {n} task(s) in sche_tasks/*.json · active (reflect/scheduler.py running)', + 'scheduler.cron.inactive': 'cron: {n} task(s) in sche_tasks/*.json · inactive (start reflect/scheduler.py to schedule)', + 'scheduler.empty': '(no startable services: both reflect/*.py and frontends/*app*.py are empty)', + 'scheduler.no_pick': '(no service picked)', + 'scheduler.no_change': '(no change vs running set)', + 'scheduler.running_tag': ' · running', + 'scheduler.confirm.title': 'Ready to submit your answer?', + 'scheduler.confirm.hint': '←/→ pick · Enter confirm · Esc go back', + 'scheduler.confirm.submit': 'Submit ({n} service: {names})', + 'scheduler.confirm.edit': 'Edit selection', + 'scheduler.diff.start': '▶ start {n}: {names}', + 'scheduler.diff.stop': '■ stop {n}: {names}', + 'scheduler.cancelled': 'Cancelled — no change applied', + 'scheduler.back_to_pick': 'Back to the picker', + 'scheduler.usage_start': 'Usage: /scheduler start [,...]', + + # export picker + 'export.title': 'Export the last reply', + 'export.opt.clip': 'Copy to clipboard', + 'export.opt.file': 'Save to file (temp/)', + 'export.opt.all': 'Show full log path', + + # rewind picker + 'rewind.title': 'Rewind to which turn', + 'rewind.option': 'rewind {n} turn(s) · {preview}', + + # language picker + 'lang.title': 'Switch interface language', + + # verbose + 'verbose.title': ' Tool Trace', + 'verbose.hint': ' ↑↓ pick · PgUp/Dn scroll · Enter switch[{field}] · c copy · e export · q quit', + 'verbose.empty': '(empty)', + + # answer prefix when committing user reply to ask_user + 'msg.answer_prefix': '[ans] {text}', + + # pending input preview (queued while agent is busy) + 'pending.head_running': 'queued {n} · injecting at next turn boundary · Esc to clear', + 'pending.cleared': 'cleared {n} pending message(s)', + 'pending.queued_marker': '[queued] {text}', + # Soft-guidance wrap: treat a mid-task user message as steering input + # for the ongoing task, rather than a deferred must-answer queue item. + 'pending.inject_wrap': ('User sent a message while you were ' + 'working:\n{text}\n' + 'Please take it into consideration and ' + 'adjust direction if needed.'), + + # shell-mode magic (`!` prefix) + 'shell.hint': '! for shell mode', + 'shell.timeout': '[shell: timeout {sec}s]', + 'shell.error': '[shell error: {err}]', + 'shell.empty': '(no output)', + 'shell.history': '[!shell {sh}] {cmd}\n```\n{out}\n```\n(exit {rc})', + + # /resume + 'cmd.resume.desc': 'list recent sessions and pick one to recover', + 'help.resume': ' /resume List recent sessions and recover one', + }, + + 'zh': { + # /help intro & rows — 措辞与 v2 COMMANDS 表对齐。 + 'help.title': '命令:', + 'help.help': ' /help 显示帮助', + 'help.status': ' /status 查看会话状态', + 'help.sessions': ' /sessions 列出所有会话', + 'help.llm': ' /llm [n] 查看 / 切换模型', + 'help.btw': ' /btw 旁问 — 不打断主 agent', + 'help.review': ' /review [request] in-session 代码审查(直接输出报告)', + 'help.rewind': ' /rewind [n] 回退最近 n 轮', + 'help.continue': ' /continue [n|name] 列出 / 恢复历史会话', + 'help.new': ' /new [name] 新建会话(清空当前会话)', + 'help.rename': ' /rename 重命名当前会话', + 'help.clear': ' /clear 清空显示(不动 LLM 历史)', + 'help.cost': ' /cost 显示当前会话 token 用量', + 'help.verbose': ' /verbose 工具调用审计(↑↓ 选 · Enter 切换 · c 复制 · q 退)', + 'help.export': ' /export [sub] 导出最后回复:clip / file [name] / all', + 'help.stop': ' /stop 中止当前任务', + 'help.language': ' /language [code] 查看 / 切换界面语言', + 'help.update': ' /update [备注] 预览 upstream 提交与 diff,再 git pull(不 commit)', + 'help.autorun': ' /autorun [seed] 进入 autonomous_operation 自主模式', + 'help.morphling': ' /morphling [target] 启用 Morphling 蒸馏 / 吞噬外部技能', + 'help.goal': ' /goal [goal] 进入 Goal 模式(需 condition 约束)', + 'help.hive': ' /hive [target] 进入 Hive 多 worker 协作模式', + 'help.conductor': ' /conductor [task] 交给 conductor.py 做多 subagent 编排', + 'help.scheduler': ' /scheduler 多选启动 reflect 任务 / 查看 cron', + 'help.emoji': ' /emoji [style] 切换 spinner 宠物样式(picker / 直接传参)', + 'help.quit': ' /quit 退出', + 'help.esc': ' Esc 撤回提问 · 清草稿 · 停任务(不退出)', + 'help.cc': ' Ctrl+C × 2 退出(空闲时;运行中只 abort 任务)', + 'help.cl': ' Ctrl+L 强制重画(睡眠唤醒后修复)', + 'help.cz': ' Ctrl+Z / Ctrl+Y 撤销 / 重做 输入框编辑', + 'help.shift_arrow': ' Shift+←→↑↓ 选中文字(Ctrl+C 复制 / Ctrl+X 剪切 / Ctrl+A 全选)', + + # _CMDS palette entries — 与 /help 同源,命令面板单行显示。 + 'cmd.help.desc': '显示帮助', + 'cmd.status.desc': '查看会话状态', + 'cmd.llm.desc': '查看 / 切换模型', + 'cmd.btw.desc': '旁问 — 不打断主 agent', + 'cmd.review.desc': 'in-session 代码审查', + 'cmd.rewind.desc': '回退最近 n 轮', + 'cmd.continue.desc': '列出 / 恢复历史会话', + 'cmd.new.desc': '新建会话', + 'cmd.rename.desc': '重命名当前会话', + 'cmd.clear.desc': '清空显示(不动 LLM 历史)', + 'cmd.cost.desc': '显示当前会话 token 用量', + 'cmd.verbose.desc': '工具调用审计', + 'cmd.export.desc': '导出最后回复(剪贴板/文件/日志路径)', + 'cmd.stop.desc': '中止当前任务', + 'cmd.language.desc': '查看 / 切换界面语言', + 'cmd.quit.desc': '退出', + + # arg hints — 与 v2 对齐:小写 n、完整的 question 等。 + 'cmd.llm.arg': '[n]', + 'cmd.btw.arg': '', + 'cmd.review.arg': '[request]', + 'cmd.rewind.arg': '[n]', + 'cmd.continue.arg': '[n|name]', + 'cmd.new.arg': '[name]', + 'cmd.rename.arg': '', + 'cmd.export.arg': '[clip|file|all]', + 'cmd.language.arg': '[code]', + 'cmd.update.arg': '[备注]', + 'cmd.update.desc': '预览 upstream 提交与 diff,再 git pull(不 commit)', + 'cmd.autorun.arg': '[seed]', + 'cmd.autorun.desc': '进入 autonomous_operation 自主模式', + 'cmd.morphling.arg': '[target]', + 'cmd.morphling.desc': '启用 Morphling 蒸馏 / 吞噬外部技能', + 'cmd.goal.arg': '[goal]', + 'cmd.goal.desc': '进入 Goal 模式(需 condition 约束)', + 'cmd.hive.arg': '[target]', + 'cmd.hive.desc': '进入 Hive 多 worker 协作模式', + 'cmd.conductor.arg': '[任务]', + 'cmd.conductor.desc': '调用 frontends/conductor.py 做多 subagent 编排', + 'cmd.scheduler.desc': '多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)', + 'cmd.emoji.arg': '[样式]', + 'cmd.emoji.desc': '切换 spinner 宠物表情 — 打开 picker;带参数则直接切换', + + # status line + 'status.asking': '◉ 待答 · Esc 撤回提问', + 'status.running.tail': ' · Esc 停', + 'status.tps': ' · {rate:.0f} tok/s', + 'status.cc_confirm': '再按 Ctrl+C 退出', + 'status.ready': '○ 就绪', + + # /status + 'status.title': ' 会话状态', + 'status.label.model': 'model:', + 'status.label.state': 'state:', + 'status.label.rounds': 'rounds:', + 'status.label.context': 'context:', + 'status.label.cwd': 'cwd:', + 'status.state.running': 'running · {verb} {elapsed}', + 'status.state.waiting': 'waiting · ask_user pending', + 'status.state.idle': 'idle', + 'status.ctx.unknown': 'n/a', + 'status.ctx.fmt': '{used:,} / {cap:,} ctx', + + # banner + 'banner.label.model': 'model:', + 'banner.label.directory': 'directory:', + 'banner.label.session': 'session:', + 'banner.session.single': '单会话 · scrollback', + 'banner.llm_hint': '/llm 切换', + + # messages + 'msg.ask_cancelled': '✗ 已撤回提问 · 可直接输入或重新发问', + 'msg.abort_requested': '⏹ 已请求中止 · Esc', + 'msg.abort_done': '⏹ 已请求中止', + 'msg.idle_no_task': '(空闲,无任务)', + 'msg.cleared': '新对话 · 上下文已清空', + 'msg.new_session': '新会话 · 上一段对话已清空', + 'msg.new_session_named': '新会话「{name}」· 上一段对话已清空', + 'msg.renamed': '✎ 会话已重命名为「{name}」', + 'msg.rewind': '↩ 回退 {n} 轮(移除 {removed} 条历史;scrollback 不可改,以此为界)', + 'msg.no_rewindable': '没有可回退的轮次', + 'msg.continue_loading': '┄┄ 载入 {name},以下为完整上文 ┄┄', + 'msg.continue_ready': '┄┄ {msg} · 接着说即可 ┄┄', + 'msg.llm_switched': 'LLM → {name}', + 'msg.export_done': '已导出: {path}', + 'msg.export_clipped': '已复制到剪贴板({n} 字符)', + 'msg.export_clip_failed': '❌ 复制失败:未找到剪贴板工具', + 'msg.export_all': '完整日志:\n{path}', + 'msg.export_all_missing': '尚无日志文件', + 'msg.review_empty': '(review 无输出)', + 'msg.no_export': '(没有可导出的回答)', + 'msg.no_tracker': '(暂无统计)', + 'msg.no_history': ' 没有可恢复的历史会话', + 'msg.no_llms': '(无可用 LLM)', + 'msg.no_tools': ' (暂无工具调用记录)', + 'msg.lang_current': '当前界面语言:{label}({code})', + 'msg.lang_switched': '界面语言 → {label}', + 'msg.btw_no_answer': '(无回答)', + 'btw.title': '旁问 · Esc 清空', + 'btw.querying': ' ⋯ 查询中…', + + # plan card + 'plan.header': '计划 ({done}/{total})', + 'plan.complete': '✓ 计划完成 ({n}/{n})', + 'plan.placeholder': '计划模式已激活', + 'plan.waiting': '等待写入 {path} …', + 'plan.overflow': '还有 {n} 项', + + # errors + 'err.running_blocked': '运行中,先 /stop 再用该命令', + 'err.continue_usage': '用法: /continue 或 /continue N', + 'err.index_oob': '❌ 索引越界(有效 1-{max})', + 'err.btw_usage': '用法: /btw <旁问>(不污染主上下文)', + 'err.rewind_usage': '用法:/rewind ', + 'err.rename_usage': '用法:/rename ', + 'err.rewind_range': '❌ 回退失败:n 应在 1-{max}', + 'err.lang_usage': '用法:/language [code] (可选 code:{available})', + 'err.lang_unknown': '未知语言代码:{code} (可选:{available})', + 'err.unknown_cmd': '未知命令 /{name} — /help 看可用命令', + 'err.multi_session': '/{name}:多会话后端尚未接入,命令已预留但暂未实现', + 'err.menu_cb': '❌ 菜单回调失败: {err}', + 'err.no_llm': 'No LLM configured — check mykey.py', + 'err.no_tty': 'tui_v3: needs a real TTY (run it in iTerm directly)', + 'err.dep_missing': 'Error: {name} is not installed.', + 'err.dep_install': 'Install with: pip install rich prompt_toolkit', + + # menu / picker / palette + 'menu.default_title': '选择', + 'menu.hint': '↑↓ 选 · Enter 确认 · Esc 取消', + 'menu.hint.filter': '输入过滤 · ↑↓ 选 · Enter 确认 · Esc 取消', + 'menu.search': '输入筛选工作区或输入绝对路径回车新建工作区', + 'menu.no_match': '无匹配', + 'menu.free.hint': '回车以该路径新建/进入', + 'ask.default_q': '请回答:', + 'ask.title': '◉ 请回答', + 'ask.pending': ' +{n} 待答', + 'ask.hint.multi': '↳ ↑↓ 移动 · Space 标记 · Enter 提交 · Esc 撤回', + 'ask.hint.single': '↳ ↑↓ 切换(选项 ⇄ 输入框)· Enter 确认 · Esc 撤回', + 'ask.hint.freetext': '↳ ↑↓ 回到选项 · 输字符输入 · Enter 提交 · Esc 撤回', + + # continue picker + 'continue.title': '恢复历史会话', + 'continue.occupied.title': '该会话正被占用(pid {p})—— 从原会话拷贝一份继续?', + 'continue.occupied.copy': '拷贝一份继续', + 'continue.occupied.cancel': '取消', + # /workspace(与 v2 一致;后端 workspace_cmd.py) + 'cmd.workspace.arg': '[path|off]', + 'cmd.workspace.desc': '设定工作目录(绝对路径)并进入项目模式', + 'ws.entered': '✅ 已进入 workspace「{n}」', + 'ws.fail': '❌ workspace 设定失败: {e}', + 'ws.exited': '已退出 workspace(项目模式关闭;junction 与文件保留)', + 'ws.inactive': '当前未处于 workspace 模式', + 'ws.none': '暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入', + 'ws.pick.title': '选择 workspace(↑↓ 选择,Enter 确认,Esc 取消)', + 'ws.restored': '已恢复工作目录: {t}', + 'continue.row.fmt': '{rel:>4} {rounds:>3}轮 {preview}', + 'continue.unit.round': '轮', + + # llm picker + 'llm.title': '切换 LLM', + + # emoji picker + 'emoji.title': '选择 spinner 宠物样式', + 'emoji.switched': '宠物样式 → `{style}`', + 'emoji.unknown': '未知样式 `{choice}` — 可选:{valid}', + 'emoji.row.current': '● {name:<8} {sample}', + 'emoji.row.other': ' {name:<8} {sample}', + 'emoji.row.off': '(隐藏 pet)', + + # /scheduler picker (multi-pick reflect tasks / frontends) + 'scheduler.pick.title': '挑选要启动的服务(已勾选 = 运行中,取消勾选即停止)', + 'scheduler.pick.hint': 'Space 勾选 · ↑↓ 移动 · Enter 下一步 · Esc 取消 · 或 /scheduler start a,b,c', + 'scheduler.cron.active': 'cron:sche_tasks/*.json 共 {n} 个任务 · 已激活(reflect/scheduler.py 在运行)', + 'scheduler.cron.inactive': 'cron:sche_tasks/*.json 共 {n} 个任务 · 未激活(启动 reflect/scheduler.py 才会调度)', + 'scheduler.empty': '(没有可启动的服务:reflect/*.py 与 frontends/*app*.py 均为空)', + 'scheduler.no_pick': '(未选择任何服务)', + 'scheduler.no_change': '(与当前运行集合相比无变化)', + 'scheduler.running_tag': ' · 运行中', + 'scheduler.confirm.title': '确认提交本次改动?', + 'scheduler.confirm.hint': '←/→ 选择 · Enter 确认 · Esc 回退', + 'scheduler.confirm.submit': '提交({n} 个服务:{names})', + 'scheduler.confirm.edit': '回去修改选择', + 'scheduler.diff.start': '▶ 启动 {n}:{names}', + 'scheduler.diff.stop': '■ 停止 {n}:{names}', + 'scheduler.cancelled': '已取消,未变更任何服务', + 'scheduler.back_to_pick': '已回到选择界面', + 'scheduler.usage_start': '用法:/scheduler start <服务名>[,<服务名2>...]', + + # export picker + 'export.title': '导出最后回复', + 'export.opt.clip': '复制到剪贴板', + 'export.opt.file': '保存到文件(temp/)', + 'export.opt.all': '显示完整日志路径', + + # rewind picker + 'rewind.title': '选择回退到的轮次', + 'rewind.option': '回退 {n} 轮 · {preview}', + + # language picker + 'lang.title': '切换界面语言', + + # verbose + 'verbose.title': ' Tool Trace', + 'verbose.hint': ' ↑↓ 选 · PgUp/Dn 滚 · Enter 切换[{field}] · c 复制 · e 导出 · q 退', + 'verbose.empty': '(空)', + + # answer prefix + 'msg.answer_prefix': '[答] {text}', + + # pending input preview + 'pending.head_running': '已排队 {n} 条 · 下个 turn 边界注入 · Esc 清空', + 'pending.cleared': '已清空 {n} 条待发送消息', + 'pending.queued_marker': '[排队] {text}', + 'pending.inject_wrap': ('用户在你工作时发来了一条新消息:\n{text}\n' + '请将其纳入考虑,必要时调整方向。'), + + # shell-mode magic (`!` prefix) + 'shell.hint': '! 进入 shell 模式', + 'shell.timeout': '[shell:{sec}s 超时]', + 'shell.error': '[shell 错误:{err}]', + 'shell.empty': '(无输出)', + 'shell.history': '[!shell {sh}] {cmd}\n```\n{out}\n```\n(退出码 {rc})', + + # /resume + 'cmd.resume.desc': '列出最近会话并恢复其中一个', + 'help.resume': ' /resume 列出最近会话并恢复其中一个', + }, +} + + +def t(key: str, **fmt) -> str: + """Translate `key` to current language; fall back to en then to key itself. + Missing format fields raise KeyError — caller bug, not i18n bug.""" + val = _I18N.get(_LANG, {}).get(key) + if val is None and _LANG != 'en': + val = _I18N.get('en', {}).get(key) + if val is None: + val = key # visible breadcrumb for missing keys + if fmt: + try: + return val.format(**fmt) + except (KeyError, IndexError, ValueError): + return val + return val + + +def tip_count() -> int: + """Number of rotating banner tips (same for every language).""" + return len(_TIPS['en']) + + +def tip(idx: int) -> str: + """Banner tip at `idx`, resolved in the current language so a /language + switch relabels it on the next banner repaint.""" + pool = _TIPS.get(_LANG) or _TIPS['en'] + return pool[idx % len(pool)] if pool else '' + + +def get_lang() -> str: + return _LANG + + +def set_lang(code: str) -> bool: + """Switch active language and persist. Returns True on success.""" + global _LANG + if code not in _SUPPORTED: + return False + _LANG = code + _save_settings({'lang': code}) + return True + + +def supported() -> tuple[str, ...]: + return _SUPPORTED + + +def _detect_system_lang() -> str: + """System language: check LC_ALL → LC_MESSAGES → LANG → locale.getlocale(). + Prefix `zh` → 'zh', else 'en'.""" + for env in ('LC_ALL', 'LC_MESSAGES', 'LANG'): + v = os.environ.get(env) + if v: + if v.lower().startswith('zh'): + return 'zh' + return 'en' + try: + loc = locale.getlocale()[0] or '' + if loc.lower().startswith('zh'): + return 'zh' + except Exception: + pass + return _DEFAULT + + +def _load_settings() -> dict: + try: + with open(_SETTINGS_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_settings(patch: dict) -> None: + cur = _load_settings() + cur.update(patch) + try: + os.makedirs(os.path.dirname(_SETTINGS_PATH), exist_ok=True) + with open(_SETTINGS_PATH, 'w', encoding='utf-8') as f: + json.dump(cur, f, ensure_ascii=False, indent=2) + except Exception: + pass + + +def init_lang() -> str: + """Resolve and install initial language: persisted > system > default. + Call once at startup; returns the resolved code.""" + global _LANG + saved = _load_settings().get('lang') + if saved in _SUPPORTED: + _LANG = saved + else: + _LANG = _detect_system_lang() + return _LANG + + +# `_t` alias kept so the hundreds of existing `_t(...)` call sites are untouched. +_t = t + +# Resolve language once on import so any module-level string (banner, _CMDS, +# /help) sees the right locale. +init_lang() +# ════════════════════════════════════════════════════════════════════════════ + + +# Module-level `clip` shim: keep sb.py-style `clip.copy(...)` calls +# working without a separate clipboard module — the underlying funcs +# (copy, paste, paste_image) are defined later in this same file. +class _Clip: + @staticmethod + def copy(text): return copy(text) + @staticmethod + def paste(): return paste() + @staticmethod + def paste_image(): return paste_image() +clip = _Clip() + + +def _enable_windows_vt_mode() -> None: + """Enable UTF-8 + ANSI escape processing on Windows consoles when possible.""" + if not _IS_WINDOWS: + return + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + # Make classic conhost/cmd decode UTF-8 bytes written by _w(). This is + # harmless in mintty/Git Bash where these calls usually fail because the + # std handles are pipes/ptys rather than Win32 console handles. + kernel32.SetConsoleOutputCP(65001) + kernel32.SetConsoleCP(65001) + enable_vt = 0x0004 + for handle_id in (-11, -12): # STD_OUTPUT_HANDLE, STD_ERROR_HANDLE + handle = kernel32.GetStdHandle(handle_id) + mode = ctypes.c_uint32() + if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + kernel32.SetConsoleMode(handle, mode.value | enable_vt) + except Exception: + # Safe fallback: modern terminals usually already support ANSI/UTF-8; + # older conhost may render escape codes, but the TUI should not crash. + pass + + +def _enter_utf8_charset() -> None: + """Ask VT-compatible terminals to interpret subsequent bytes as UTF-8.""" + # ESC % G is the ISO-2022/VT sequence for selecting UTF-8. It fixes some + # mintty/Git-Bash launches where the child process inherits a legacy locale + # and mojibakes UTF-8 box drawing/CJK into CP936-looking text. + _w('\x1b%G') + + +def _ptk_keypress_to_bytes(kp) -> bytes: + """Map prompt_toolkit KeyPress objects to tui_v3's internal byte protocol. + + prompt_toolkit normalizes platform-specific console input (Win32 console, + ConPTY, mintty/msys pty) into symbolic Keys. Keep the editor core small by + translating those symbols back to the bytes already handled by _feed/_keys. + """ + try: + from prompt_toolkit.keys import Keys + except Exception: + Keys = None # type: ignore[assignment] + + key = getattr(kp, 'key', None) + data = getattr(kp, 'data', '') or '' + mods = {str(m).lower().replace('_', '-') for m in (getattr(kp, 'modifiers', None) or ())} + modded_s = str(key).lower().replace('_', '-') == 's' or data == 's' + if modded_s and any(m in mods for m in ('control', 'ctrl', 'command', 'cmd')): + return b'\x13' + + # Printable text and paste chunks. PTK may deliver a multi-character data + # string for bracketed paste/typeahead; forwarding UTF-8 preserves CJK/emoji. + if isinstance(data, str) and data and (len(data) > 1 or (len(data) == 1 and ord(data) >= 0x20 and data != '\x7f')): + return data.encode('utf-8', 'replace') + + name = getattr(key, 'name', str(key)) + key_s = str(key) + norm = name.lower().replace('_', '-').replace('keys.', '') + norm_s = key_s.lower().replace('_', '-').replace('keys.', '') + aliases = {norm, norm_s} + + def has(*needles: str) -> bool: + return any(n in a for a in aliases for n in needles) + + # Enter submits; Ctrl+J / Shift+Enter insert newline when PTK can distinguish. + # Windows terminals send \r for both Enter/Shift+Enter — detect Shift physically. + if has('controlm', 'c-m') or key_s in ('\r', '\n'): + if _IS_WINDOWS: + import ctypes + if ctypes.windll.user32.GetAsyncKeyState(0x10) & 0x8000: + return b'\n' + return b'\r' + if has('controlj', 'c-j', 's-enter', 'shift-enter'): + return b'\n' + if has('controls', 'control-s', 'c-s', 'commands', 'command-s', 'cmd-s'): + return b'\x13' + + # Navigation. Existing _keys() uses these small control bytes. + if has('up') and has('shift'): + return b'\x1c' + if has('down') and has('shift'): + return b'\x1d' + if has('left') and has('shift'): + return b'\x1e' + if has('right') and has('shift'): + return b'\x1f' + if has('up'): + return b'\x10' + if has('down'): + return b'\x0e' + if has('left'): + return b'\x02' + if has('right'): + return b'\x06' + # Home/End get dedicated bytes — 0x01 is already Ctrl+A (select-all) and + # 0x05 has no editor handler, so reusing them would break/no-op the keys. + if has('home'): + return b'\x07' + if has('end'): + return b'\x14' + if has('delete'): + return b'\x7f' + if has('backspace') or data == '\x7f' or data == '\x08': + return b'\x7f' + if has('escape') or data == '\x1b': + return b'\x1b' + + ctrl = { + 'controla': b'\x01', 'c-a': b'\x01', + 'controlb': b'\x02', 'c-b': b'\x02', + 'controlc': b'\x03', 'c-c': b'\x03', + 'controld': b'\x04', 'c-d': b'\x04', + 'controle': b'\x05', 'c-e': b'\x05', + 'controlf': b'\x06', 'c-f': b'\x06', + 'controlh': b'\x7f', 'c-h': b'\x7f', + 'controlj': b'\n', 'c-j': b'\n', + 'controlk': b'\x0b', 'c-k': b'\x0b', + 'controll': b'\x0c', 'c-l': b'\x0c', + 'controln': b'\x0e', 'c-n': b'\x0e', + 'controlp': b'\x10', 'c-p': b'\x10', + 'controlu': b'\x15', 'c-u': b'\x15', + 'controlv': b'\x16', 'c-v': b'\x16', + 'controlx': b'\x18', 'c-x': b'\x18', + 'controly': b'\x19', 'c-y': b'\x19', + 'controlz': b'\x1a', 'c-z': b'\x1a', + } + for a in aliases: + if a in ctrl: + return ctrl[a] + + if isinstance(data, str) and data: + return data.encode('utf-8', 'replace') + return b'' + + +# ──────────────────────────────────────────────────────────────────────────── +# cjk: CJK wrap monkey-patch for Rich +# ──────────────────────────────────────────────────────────────────────────── + +log = logging.getLogger(__name__) + +_CJK_RANGES = ( + (0x4E00, 0x9FFF), (0x3400, 0x4DBF), (0x20000, 0x2A6DF), + (0x2A700, 0x2B73F), (0x2B740, 0x2B81F), (0x2B820, 0x2CEAF), + (0x2CEB0, 0x2EBEF), (0xF900, 0xFAFF), (0x2F800, 0x2FA1F), + (0x3000, 0x303F), (0x3040, 0x309F), (0x30A0, 0x30FF), + (0x31F0, 0x31FF), (0xFF00, 0xFFEF), (0xAC00, 0xD7AF), +) + + +def _is_cjk(ch: str) -> bool: + cp = ord(ch) + return any(lo <= cp <= hi for lo, hi in _CJK_RANGES) + + +def _is_wide(ch: str) -> bool: + try: + from rich.cells import cell_len + return cell_len(ch) == 2 + except ImportError: + return _is_cjk(ch) + + +def install_cjk_wrap() -> bool: + """Monkey-patch Rich's word-wrap to handle CJK char-level breaks. + Returns True on success, False on fallback.""" + try: + import rich._wrap as wrap_mod + from rich.cells import cell_len + except (ImportError, AttributeError) as e: + log.warning("CJK patch skipped: %s", e) + return False + + orig_divide = getattr(wrap_mod, 'divide_line', None) + if orig_divide is None: + log.warning("CJK patch skipped: Rich lacks divide_line") + return False + + def _patched_divide_line(text, width, fold=True): + divides = set() + line_width = 0 + for i, ch in enumerate(text._text if hasattr(text, '_text') else str(text)): + char_w = cell_len(ch) if ch != '\n' else 0 + if line_width + char_w > width and line_width > 0: + if _is_wide(ch) or fold: + divides.add(i) + line_width = char_w + continue + line_width += char_w + if ch == '\n': + line_width = 0 + # Merge with original for non-CJK content + try: + orig_divides = orig_divide(text, width, fold) + divides.update(orig_divides) + except Exception: + pass + return sorted(divides) + + try: + wrap_mod.divide_line = _patched_divide_line + log.info("CJK wrap patch installed for Rich %s", _rich_version()) + return True + except Exception as e: + log.warning("CJK patch failed: %s", e) + return False + + +def _rich_version() -> str: + try: + from importlib.metadata import version + return version('rich') + except Exception: + return '?' + + +# ──────────────────────────────────────────────────────────────────────────── +# clipboard: cross-platform copy/paste via native tools +# ──────────────────────────────────────────────────────────────────────────── + +log = logging.getLogger(__name__) + +_TEMP_DIR = os.path.join(tempfile.gettempdir(), 'genericagent_tui') +_platform = sys.platform +_HAS_WAYLAND = bool(os.environ.get('WAYLAND_DISPLAY')) + + +def _run(cmd: list[str], input: bytes | None = None, timeout: float = 3.0) -> bytes | None: + try: + r = subprocess.run(cmd, input=input, capture_output=True, timeout=timeout) + return r.stdout if r.returncode == 0 else None + except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e: + log.debug("clipboard cmd %s failed: %s", cmd, e) + return None + + +def copy(text: str) -> bool: + data = text.encode('utf-8') + if _platform == 'darwin': + return _run(['pbcopy'], input=data) is not None + if _platform == 'win32': + return _run(['clip.exe'], input=data) is not None + if _HAS_WAYLAND and shutil.which('wl-copy'): + return _run(['wl-copy'], input=data) is not None + if shutil.which('xclip'): + return _run(['xclip', '-selection', 'clipboard'], input=data) is not None + if shutil.which('xsel'): + return _run(['xsel', '--clipboard', '--input'], input=data) is not None + log.warning("No clipboard tool found") + return False + + +def paste() -> str | None: + out: bytes | None = None + if _platform == 'darwin': + out = _run(['pbpaste']) + elif _platform == 'win32': + out = _run(['powershell', '-NoProfile', '-Command', 'Get-Clipboard']) + elif _HAS_WAYLAND and shutil.which('wl-paste'): + out = _run(['wl-paste', '--no-newline']) + elif shutil.which('xclip'): + out = _run(['xclip', '-selection', 'clipboard', '-o']) + elif shutil.which('xsel'): + out = _run(['xsel', '--clipboard', '--output']) + if out is not None: + return out.decode('utf-8', errors='replace') + return None + + +def paste_image() -> str | None: + """Save clipboard image to temp file, return path or None.""" + os.makedirs(_TEMP_DIR, exist_ok=True) + import time + path = os.path.join(_TEMP_DIR, f'clip_{int(time.time()*1000)}.png') + ok = False + if _platform == 'darwin': + script = ( + 'use framework "AppKit"\n' + 'set pb to current application\'s NSPasteboard\'s generalPasteboard()\n' + 'set imgData to pb\'s dataForType:"public.png"\n' + 'if imgData is missing value then error "no image"\n' + 'imgData\'s writeToFile:"' + path + '" atomically:true\n' + ) + ok = _run(['osascript', '-e', script], timeout=5.0) is not None + elif _HAS_WAYLAND and shutil.which('wl-paste'): + data = _run(['wl-paste', '-t', 'image/png']) + if data: + with open(path, 'wb') as f: + f.write(data) + ok = True + elif shutil.which('xclip'): + data = _run(['xclip', '-selection', 'clipboard', '-t', 'image/png', '-o']) + if data and len(data) > 8: + with open(path, 'wb') as f: + f.write(data) + ok = True + return path if ok and os.path.isfile(path) else None + + +def _grab_clipboard_file() -> tuple[str, bool] | None: + """Return (path, is_image) from the clipboard via PIL — ported from v2. + + PIL.ImageGrab.grabclipboard() is the one cross-platform path that also + works on Windows (osascript/xclip/wl-paste below don't). It handles two + shapes: a list of copied file paths, or a raw bitmap Image (saved to a + temp PNG). is_image distinguishes images (→ `[Image #N]`, sent to the + model) from any other file (→ `[File #N]`, expanded to its path).""" + try: + from PIL import ImageGrab, Image + data = ImageGrab.grabclipboard() + except Exception: + return None + if isinstance(data, list): + for item in data: + if isinstance(item, str) and os.path.isfile(item): + return (item, os.path.splitext(item)[1].lower() in _IMAGE_EXTS) + return None + if isinstance(data, Image.Image): + try: + os.makedirs(_TEMP_DIR, exist_ok=True) + path = os.path.join(_TEMP_DIR, f'clip_{int(time.time() * 1000)}.png') + data.save(path, 'PNG') + return (path, True) + except Exception: + return None + return None + + +def _cleanup(): + if os.path.isdir(_TEMP_DIR): + shutil.rmtree(_TEMP_DIR, ignore_errors=True) + # Drop this run's signal dir if it never accumulated an in-flight file. + try: _rmdir_if_empty(os.path.join(_ROOT, 'temp', f'_tui_v3_{os.getpid()}')) + except Exception: pass + +atexit.register(_cleanup) + + +# ──────────────────────────────────────────────────────────────────────────── +# renderer: markdown / ANSI sanitisation / fold +# ──────────────────────────────────────────────────────────────────────────── + +# Comprehensive ANSI sanitization — matches v2's thoroughness +_ANSI_INCOMPLETE_RE = re.compile(r'\x1b\[[0-9;]*$') +_ANSI_DEC_PRIVATE_RE = re.compile(r'\x1b\[\?[0-9;]*[a-zA-Z]') +_ANSI_OSC_RE = re.compile(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?') +_ANSI_MODE_SET_RE = re.compile(r'\x1b[=>][0-9]*') +# Keep SGR (color) codes, strip everything else +_ANSI_SGR_RE = re.compile(r'\x1b\[[0-9;]*m') + +# agent_loop.py emits `**LLM Running (Turn N) ...**` by default but switches +# to the short `**Turn N ...**` when `handler.parent.task_dir` is set +# (agent_loop.py:52). TUI v3 sets task_dir (for `_stop` signal + `!cmd` +# shell history via `_intervene`), so we must match both forms. +_TURN_MARKER_RE = re.compile(r'\*\*(?:LLM Running \()?Turn (\d+)\)?[^\n]*?\*\*') +_META_TAG_RE = re.compile(r'<(?:thinking|summary|tool_use|file_content)>.*?', re.DOTALL) +_TOOL_USE_BLOCK_RE = re.compile(r'```json\s*\{[^}]*"tool_name"[^}]*\}\s*```', re.DOTALL) +_TOOL_USE_TAG_RE = re.compile(r'\s*\{.*?"tool_name"\s*:\s*"([^"]+)".*?\}\s*', re.DOTALL) +_SUMMARY_RE = re.compile(r'\s*(.*?)\s*', re.DOTALL) +_QUAD_BACKTICK_RE = re.compile(r'(`{4,})') +_ASK_USER_RE = re.compile(r'"tool_name"\s*:\s*"ask_user".*?"question"\s*:\s*"([^"]*)"', re.DOTALL) + +# v2 fold_turns helpers (tuiapp_v2.py:240-267). 4-fence stash keeps a tool +# result's `` ``` `` from being misread as a turn boundary; the per-turn +# `` regex uses a negative lookahead so two adjacent summaries don't +# merge; the title cleaner strips fenced code + thinking before extraction. +_FENCE4_STASH_RE = re.compile(r'^`{4,}.*?^`{4,}\n?', re.DOTALL | re.MULTILINE) +# Same as _TURN_MARKER_RE but capturing the WHOLE match for str.split() to +# keep the marker as a separator token in the result list. +_TURN_SPLIT_FOLD_RE = re.compile(r'(\*\*(?:LLM Running \()?Turn \d+\)? \.\.\.\*\*)') +_SUMMARY_PERTURN_RE = re.compile(r'\s*((?:(?!).)*?)\s*', re.DOTALL) +_TITLE_CLEAN_RE = re.compile(r'`{3,}.*?`{3,}|.*?', re.DOTALL) +_TITLE_ARGS_TAIL_RE = re.compile(r',?\s*args:.*$') + + +@dataclass +class FoldSegment: + title: str + body: str + turn: int + is_last: bool = False + + +@dataclass +class Block: + """A unit of finalized scrollback history, stored as SOURCE (not rendered + lines). Resize replays each block through its renderer at the new width, + so width-baked structures (chip boxes, banner box) reflow correctly. The + actual scrollback bytes above the viewport stay frozen at the old width — + that's a terminal physics constraint — but the viewport and everything + new flows correctly.""" + kind: str # 'user' | 'assistant' | 'plain' | 'banner' + source: str # source text (or '' for banner — regenerated on demand) + tool_n: int = 0 # tool count cached at last render (for stable tids) + + +def sanitize_ansi(text: str) -> str: + """Strip non-SGR ANSI escapes and incomplete sequences from streaming chunks.""" + text = _ANSI_DEC_PRIVATE_RE.sub('', text) + text = _ANSI_OSC_RE.sub('', text) + text = _ANSI_MODE_SET_RE.sub('', text) + text = _ANSI_INCOMPLETE_RE.sub('', text) + return text + + +def _render_checkboxes(text: str) -> str: + """Convert markdown task lists to visual checkboxes.""" + text = re.sub(r'^(\s*[-*+]\s)\[ \]', r'\1☐', text, flags=re.MULTILINE) + text = re.sub(r'^(\s*[-*+]\s)\[x\]', r'\1☑', text, flags=re.MULTILINE | re.IGNORECASE) + return text + + +def strip_meta_tags(text: str) -> str: + """Strip internal tags, render tool_use as readable summaries.""" + def _tool_replace(m): + name = m.group(1) + if name == 'ask_user': + q_match = _ASK_USER_RE.search(m.group(0)) + if q_match: + return f'> {q_match.group(1)}' + return f'🔧 {name}' + text = _TOOL_USE_TAG_RE.sub(_tool_replace, text) + text = _META_TAG_RE.sub('', text) + text = _TOOL_USE_BLOCK_RE.sub('', text) + text = _render_checkboxes(text) + return re.sub(r'\n{3,}', '\n\n', text).strip() + + +def _extract_title(text: str, max_len: int = 72) -> str: + m = _SUMMARY_RE.search(text) + if m: + title = m.group(1).strip() + else: + first = text.strip().split('\n')[0] if text.strip() else '' + title = re.sub(r'^[#*>\-\s]+', '', first).strip() + if len(title) > max_len: + title = title[:max_len - 1] + '…' + return title or '...' + + +def fold_segments(text: str) -> list[FoldSegment]: + """Split agent response into per-turn fold segments.""" + if not text: + return [] + cleaned = _QUAD_BACKTICK_RE.sub(lambda m: '~' * len(m.group(1)), text) + parts = _TURN_MARKER_RE.split(cleaned) + if len(parts) <= 1: + return [FoldSegment(title=_extract_title(text), body=text, turn=1, is_last=True)] + segments: list[FoldSegment] = [] + if parts[0].strip(): + segments.append(FoldSegment(title=_extract_title(parts[0]), body=parts[0], turn=0)) + for i in range(1, len(parts), 2): + turn_num = int(parts[i]) if i < len(parts) else len(segments) + 1 + body = parts[i + 1] if i + 1 < len(parts) else '' + body = body.replace('~' * 4, '````') + segments.append(FoldSegment(title=_extract_title(body), body=body, turn=turn_num)) + if segments: + segments[-1].is_last = True + return segments + + +# Render cache: (content_hash, width) -> rendered object +_render_cache: dict[tuple[int, int], object] = {} +_CACHE_MAX = 200 + + +class HardBreakMarkdown(Markdown): + """Markdown that treats softbreaks as hardbreaks, preserving code blocks.""" + def __init__(self, markup: str, **kwargs): + lines = [] + in_code = False + for line in markup.split('\n'): + stripped = line.strip() + if stripped.startswith('```'): + in_code = not in_code + if in_code: + lines.append(line) + else: + lines.append(line + ' ') + super().__init__('\n'.join(lines), **kwargs) + + +def _markdown_to_text(cleaned: str, width: int) -> Text: + """Render Markdown to a CONCRETE Text (v2 approach). A Textual Static holding + a live rich.markdown.Markdown does not re-composite reliably when scrolled + past the viewport (height measurement is unstable → frozen/blank scroll); + a pre-rendered Text has a fixed line count and scrolls correctly.""" + from io import StringIO + from rich.console import Console + buf = StringIO() + Console(file=buf, width=max(1, width), force_terminal=True, + color_system='truecolor', legacy_windows=False + ).print(HardBreakMarkdown(cleaned), end='') + return Text.from_ansi(buf.getvalue().rstrip('\n')) + + +def render_message(text: str, role: str = 'assistant', width: int = 0) -> Text: + """Render a message to a concrete Text. width<=0 → provisional plain text + (the widget re-renders via on_resize once its real width is known).""" + cleaned = strip_meta_tags(text) if role == 'assistant' else text + if not cleaned.strip(): + cleaned = '...' + if role == 'system': + return Text(cleaned, style='dim') + if width <= 0: + return Text(cleaned) + key = (hash(cleaned), width) + cached = _render_cache.get(key) + if cached is not None: + return cached + try: + result = _markdown_to_text(cleaned, width) + except Exception: + result = Text(cleaned) + if len(_render_cache) >= _CACHE_MAX: + for k in list(_render_cache.keys())[:_CACHE_MAX // 4]: + _render_cache.pop(k, None) + _render_cache[key] = result + return result + + +# ──────────────────────────────────────────────────────────────────────────── +# protocol: AgentBridge + typed events over display_queue +# ──────────────────────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class StreamEvent: + text: str + turn: int = 0 + source: str = "user" + +@dataclass(frozen=True) +class DoneEvent: + text: str + turn: int = 0 + source: str = "user" + outputs: list[str] = field(default_factory=list) + +@dataclass(frozen=True) +class AskUserEvent: + question: str + candidates: list[str] = field(default_factory=list) + +@dataclass(frozen=True) +class SystemEvent: + text: str + +@dataclass(frozen=True) +class ErrorEvent: + message: str + exception: Exception | None = None + +AgentEvent = StreamEvent | DoneEvent | AskUserEvent | SystemEvent | ErrorEvent + +_HOOK_KEY = '_tui_v3_ask_user' + + +def _extract_ask_user(ctx: dict | None) -> AskUserEvent | None: + er = (ctx or {}).get('exit_reason') or {} + if er.get('result') != 'EXITED': + return None + payload = er.get('data') or {} + if payload.get('status') != 'INTERRUPT' or payload.get('intent') != 'HUMAN_INTERVENTION': + return None + data = payload.get('data') or {} + candidates = data.get('candidates') or [] + # v2 parity: skip the ask card when the agent didn't supply candidates — + # the 'Waiting for your answer ...' marker already lands in scrollback as + # part of the assistant stream, and the user replies via the normal input + # box. Pushing an empty-candidate event onto the queue would route us + # through _enter_ask → free-text ask card, which freezes the live region + # in some terminals. + if not candidates: + return None + return AskUserEvent( + question=data.get('question', ''), + candidates=candidates, + ) + + +class AgentBridge: + """Wraps GenericAgent for the TUI. One bridge per session.""" + + def __init__(self, llm_no: int = 0): + self.agent = GeneraticAgent() + self.agent.llm_no = llm_no + if llm_no and hasattr(self.agent, 'llmclients') and self.agent.llmclients: + self.agent.llmclient = self.agent.llmclients[llm_no % len(self.agent.llmclients)] + self.agent.inc_out = True + self.agent.verbose = True + from frontends.slash_cmds import COMMIT_SIGNATURE_PROMPT + self.agent.extra_sys_prompts.append(COMMIT_SIGNATURE_PROMPT) + # 默认普通模式:设 None 让 project_mode 插件不读 pid 文件锚(与 v2 一致)。 + # /workspace 绑定时改为项目名 + 真实路径。 + self.agent._ga_project_mode_name = None + self.agent._ga_project_mode_workspace_path = '' + # task_dir path enables ga's `_keyinfo` / `_intervene` consume paths. + # PID-scoped so concurrent v3 processes don't share signal files. + # Only the *path* is set here; the dir is created lazily by the writer + # (`inject_intervene`) when a signal is actually injected. Eager + # makedirs left a stale empty `temp/_tui_v3_` behind for every + # run that never used intervene; `consume_file` tolerates a missing dir. + self.agent.task_dir = os.path.join(_ROOT, 'temp', f'_tui_v3_{os.getpid()}') + self.ask_user_queue: queue.Queue[AskUserEvent] = queue.Queue() + # Wrapped user messages we appended to `_intervene` since the last + # turn boundary. At a non-exit boundary the file was consumed and + # next_prompt now carries our text — clear the list. At an exit + # boundary the file was consumed but next_prompt is discarded — replay + # via put_task so the user's words aren't lost. + self._intervene_pending: list[str] = [] + self._intervene_lk = threading.Lock() + # Display queue created when an exit-boundary replay re-submits queued + # user messages (see `_on_turn_end`). Handed to the UI via + # `take_replay_dq` so it drains the follow-up run; without this the + # replayed turn streams headless — recorded in model_responses but + # never shown in the current TUI session. + self._replay_dq: queue.Queue | None = None + self._install_hook() + self._healthy = True + self._init_error: str | None = None + if not getattr(self.agent, 'llmclient', None): + self._healthy = False + self._init_error = _t('err.no_llm') + # 原地复原:本会话出生即持有自己日志的锁,使占用检测对它可见(别的会话据此判活)。 + try: + from frontends import continue_cmd as _cc + _cc.acquire_birth_lock(self.agent) + except Exception: + pass + self._runner = threading.Thread(target=self._run_safe, daemon=True, name=f'ga-tui-agent') + self._runner.start() + + def inject_intervene(self, text: str, *, track: bool = False) -> bool: + """Append `text` to `/_intervene`. ga.turn_end_callback + consumes the file at the next turn boundary and prepends `[MASTER] + ...` to next_prompt. Returns False when the agent is idle (caller + falls back to put_task). Append-mode keeps us idempotent under + the consume_file race. `track=True` records the text so the + turn_end hook can replay it on an exit boundary (used for queued + user messages — `!cmd` shell facts don't need replay).""" + td = getattr(self.agent, 'task_dir', None) + if not td or not getattr(self.agent, 'is_running', False): + return False + try: os.makedirs(td, exist_ok=True) + except Exception: return False + fp = os.path.join(td, '_intervene') + try: + sep = '' + try: + if os.path.getsize(fp) > 0: sep = '\n\n' + except OSError: pass + with open(fp, 'a', encoding='utf-8') as f: + f.write(sep + text) + if track: + with self._intervene_lk: + self._intervene_pending.append(text) + return True + except Exception: + return False + + def _run_safe(self): + try: + self.agent.run() + except Exception as e: + self._healthy = False + self._init_error = str(e) + + def _install_hook(self): + if not hasattr(self.agent, '_turn_end_hooks'): + self.agent._turn_end_hooks = {} + self.agent._turn_end_hooks[_HOOK_KEY] = self._on_turn_end + + def _on_turn_end(self, ctx: dict): + ev = _extract_ask_user(ctx) + if ev: + self.ask_user_queue.put(ev) + with self._intervene_lk: + if not self._intervene_pending: + return + if (ctx or {}).get('exit_reason'): + combined = '\n\n'.join(self._intervene_pending) + self._intervene_pending = [] + try: self._replay_dq = self.agent.put_task(combined, source='user') + except Exception: pass + else: + self._intervene_pending = [] + + def take_replay_dq(self) -> "queue.Queue | None": + """Hand off the display_queue from an exit-boundary replay once. + + If a queued mid-run user message is consumed on the same boundary that + exits the current task, ga discards next_prompt. `_on_turn_end` replays + it with put_task; the TUI must then drain that returned queue or the + reply is written only to model_responses and appears only after + /continue. + """ + with self._intervene_lk: + dq, self._replay_dq = self._replay_dq, None + return dq + + def submit(self, query: str, images: list | None = None) -> queue.Queue: + return self.agent.put_task(query, source='user', images=images) + + def abort(self): + self.agent.abort() + + @property + def is_running(self) -> bool: + return self.agent.is_running + + @property + def llm_name(self) -> str: + try: + return self.agent.get_llm_name() + except Exception: + return '?' + + @property + def llm_model(self) -> str: + """The concrete model id in use (e.g. claude-opus-4-8), not the + channel group `llm_name` returns. Empty string when unavailable.""" + try: + return self.agent.get_llm_name(model=True) or '' + except Exception: + return '' + + def list_llms(self) -> list[tuple[int, str, bool]]: + return self.agent.list_llms() + + def switch_llm(self, n: int): + self.agent.next_llm(n) + + def drain_display_queue(self, dq: queue.Queue, timeout: float = 0.25): + """Generator: yields typed events from a display_queue.""" + while True: + try: + item = dq.get(timeout=timeout) + except queue.Empty: + yield None + continue + if not isinstance(item, dict): + continue + if 'done' in item: + yield DoneEvent( + text=item['done'], + turn=item.get('turn', 0), + source=item.get('source', 'user'), + outputs=item.get('outputs', []), + ) + break + if 'next' in item: + yield StreamEvent( + text=item['next'], + turn=item.get('turn', 0), + source=item.get('source', 'user'), + ) + + +# ──────────────────────────────────────────────────────────────────────────── +# sb: scrollback-first TUI core (input, paint, flow, ask, /verbose, …) +# ──────────────────────────────────────────────────────────────────────────── + +# Prose hierarchy via ATTRIBUTES only (bold/italic/underline) — NO dim for body +# content (dim on white = unreadable grey). Keep normal prose inherited from the +# surrounding tile; avoid Rich's inline-code reverse without pinning prose dark. +_MD_THEME = Theme({ + 'markdown.h1': 'bold underline', 'markdown.h2': 'bold underline', + 'markdown.h3': 'bold', 'markdown.h4': 'bold', + 'markdown.h5': 'bold', 'markdown.h6': 'bold', + 'markdown.strong': 'bold', 'markdown.em': 'italic', + # Rich's default inline-code ``reverse`` can vanish on themed tiles; keep it + # bold but inherited so it stays readable on both light and dark surfaces. + 'markdown.code': 'bold', 'markdown.code_block': 'none', + 'markdown.block_quote': 'italic', 'markdown.hr': 'none', + 'markdown.link': 'underline', 'markdown.link_url': 'underline', + # Bullet inherits the surrounding foreground (a pinned dark hue vanished on + # dark terminals); bold alone keeps it visible on any background. + 'markdown.item.bullet': 'bold', +}, inherit=True) + + +PROMPT = '❯ ' +CONT = ' ' +# macOS Terminal.app quantises ALL truecolor escapes to their nearest 256-color +# slot, and the slot it picks for #5e6ad2 (iTerm lavender) is 62/#5f5fd7 — that's +# the "blue" border the user sees. It also renders \x1b[2m as a heavy 30%-opacity +# multiply instead of the gentle blend iTerm does — that's the "heavy shadow". +# Branching on TERM_PROGRAM lets iTerm keep its truecolor + dim look, while +# Apple_Terminal uses pinned 256-color slots that match iTerm's RENDERED result. +_IS_APPLE_TERMINAL = os.environ.get('TERM_PROGRAM') == 'Apple_Terminal' +_RST = '\x1b[0m' +if _IS_APPLE_TERMINAL: + _DIM = '\x1b[38;5;244m' # mid-gray — no \x1b[2m, no "shadow" + _ACCENT = '\x1b[38;5;105m' # 256-slot light purple, closest to iTerm rendered look + _BORDER = '\x1b[38;5;146m' # light lavender +else: + _DIM = '\x1b[2m' + _ACCENT = '\x1b[38;2;94;106;210m' # Linear lavender #5e6ad2 + _BORDER = '\x1b[38;5;146m' +_INK_U = '\x1b[38;5;234m' # user ink — kept for legacy callers +# User-prompt panel. Charcoal block (RGB 55,55,55) with soft-white ink — +# full-row tile via _tile() means the band keeps its right edge on every +# terminal regardless of wrap-width math. Switched from xterm- +# 256 inverse (which renders muddy on Win Terminal dark themes) to truecolor. +_TILE_U = '\x1b[48;2;55;55;55m\x1b[38;2;230;230;230m' +_MARK = _ACCENT + '❯' + _RST # prompt mark — the single accent +# Shell-mode (`!` magic prefix) accents — vivid pink so it stands out from +# the normal accent purple without clashing with the heat-counter reds. +_SHELL_ACCENT = '\x1b[38;5;205m' # hot pink for border / prompt mark +_SHELL_BG = '\x1b[48;2;65;60;65m' # 65,60,65 charcoal-magenta (per spec) +_SHELL_MARK = _SHELL_ACCENT + '!' + _RST +# Full-row tile for committed shell rows (echo + each output line), so the +# pair reads as one block in scrollback, matching cc-style. Slightly +# warmer than _TILE_U (55,55,55) so the two row kinds are distinguishable +# when interleaved. Black-terminal only — light themes get the bare band. +_TILE_SHELL = '\x1b[48;2;65;60;65m\x1b[38;2;230;230;230m' +_BG_TOK = {str(n) for n in list(range(40, 48)) + [49] + list(range(100, 108))} +_SGR_RE = re.compile(r'\x1b\[([0-9;]*)m') +_CSI_ERASE_RE = re.compile(r'\x1b\[[0-9;?]*[JK]') +_SGR_TOKEN_RE = re.compile(r'\x1b\[[0-9;]*m') + + +def _tile(s: str, style: str, width: int | None = None) -> str: + # Re-assert style after every reset so muted-markdown \x1b[0m can't punch + # a hole in the block. When `width` is provided we pad with explicit + # bg-active spaces — prompt-toolkit's cell renderer doesn't honour + # \x1b[K (erase-to-EOL) inside its own buffer, so PTK-bound scrollback + # would otherwise leave the row gap exposed. Fall back to \x1b[K for + # legacy callers writing straight to the terminal (no PTK), where the + # erase command still fills correctly. + body = style + s.replace(_RST, _RST + style) + if width is None: + return body + '\x1b[K' + _RST + visible = _SGR_TOKEN_RE.sub('', s) + pad = max(0, width - cell_len(visible)) + return body + ' ' * pad + _RST + + +def _border(left: str, right: str, width: int, style: str = _BORDER) -> str: + width = max(1, width) + if width == 1: + return style + left + _RST + return style + left + '─' * max(0, width - 2) + right + _RST + + +def _strip_bg(s: str) -> str: + """Drop only BACKGROUND SGR — keep foreground colour (curated syntax/diff + stays, Linear-style functional colour) but no ugly box behind code.""" + def repl(m: re.Match) -> str: + toks = m.group(1).split(';') if m.group(1) else ['0'] + out, i = [], 0 + while i < len(toks): + t = toks[i] + if t == '48': + i += 3 if (i + 1 < len(toks) and toks[i + 1] == '5') else \ + 5 if (i + 1 < len(toks) and toks[i + 1] == '2') else 1 + continue + if t in _BG_TOK: + i += 1; continue + out.append(t); i += 1 + return '\x1b[' + ';'.join(out) + 'm' if out else '\x1b[0m' + return _SGR_RE.sub(repl, s) +# CSI (`\x1b[…`) or SS3 (`\x1bO…`, application-cursor mode for Home/End/arrows) +# as whole sequences, else any 2-byte `\x1b.` — order matters so SS3 wins over `\x1b.`. +_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1bO[@-~]|\x1b.') +_FILE_REF_RE = re.compile(r'@([\w./\-~]+)') +_PASTE_PH_RE = re.compile(r'\[Pasted text #(\d+) \+\d+ lines\]') +_FILE_PH_RE = re.compile(r'\[File #(\d+)\]') +_IMG_PH_RE = re.compile(r'\[Image #(\d+)\]') +# All paste placeholders — used for whole-block delete (v2 parity): backspace +# flush against any of these wipes the entire placeholder, not one char. +_PLACEHOLDER_RES = (_PASTE_PH_RE, _IMG_PH_RE, _FILE_PH_RE) +_IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico'} +_TURN_MK_RE = re.compile( + r'\*\*(?:LLM Running \()?Turn \d+\)?[^\n]*\*\*' # native + task-mode short form + r'|^[ \t]*Turn \d+\s*\.{3,}[ \t]*$', # plain subagent form on its own line + re.M) +_TOOL_RE = re.compile( + r'🛠️ Tool: `([^`]+)`[^\n]*\n' # 1 = name + r'(`{3,})[^\n]*\n(.*?)\n\2[ \t]*\n*' # 2 = fence delim, 3 = args body + r'(?:' + r'(`{5,})[^\n]*\n(.*?)\n\4[ \t]*\n*' # 4 = result fence (5-bt), 5 = body + r'|' # ─ OR ─ + # Trail-end sentinel: either form of the `**Turn N ...**` marker, or a + # bare `Turn N ...` on its own line, or the next tool / summary tag. + r'(.*?)(?=^🛠️ Tool: `|^\*\*(?:LLM Running \()?Turn \d+|^Turn \d+ \.\.\.$|^|\Z)' # 6 = live exec trace + r')', + re.DOTALL | re.MULTILINE) +# Prompted-style tool wrappers GA models emit AS TEXT in saved logs (no +# structured tool_use block). Fold them into chips too so /continue replays +# match live mode. Whitelist = every name in assets/tools_schema.json + the +# native metadata wrappers; user HTML (
///

/