diff --git a/.gitignore b/.gitignore
index c8ca6bf0d..f1cfcd116 100644
Binary files a/.gitignore and b/.gitignore differ
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..fb9f4116e
--- /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 000000000..641d051c0
--- /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
index 8c7e2ff30..0697dea49 100644
--- a/README.md
+++ b/README.md
@@ -1,281 +1,818 @@
-# GenericAgent — 3,300 Lines to Full OS Autonomy
+
-[English](#english) | [中文](#chinese)
+
-
+# GenericAgent
-A minimalist autonomous agent framework that gives any LLM physical-level control over your PC — browser, terminal, file system, keyboard, mouse, screen vision, and mobile devices — in ~3,300 lines of Python.
+**A Minimal, Self-Evolving Autonomous Agent Framework**
-No Electron. No Docker. No Mac Mini. No 500K-line codebase. No paid installation service.
+*~3K lines of seed code · 9 atomic tools · ~100-line Agent Loop*
-## See It in Action
+
-
-
-"Order me a milk tea" — navigates a delivery app, picks items, and checks out.
-"Find GEM stocks with EXPMA golden cross, turnover > 5%" — quantitative screening via mootdx.
-
-
+
+
+
+
+
+
+
+
+
+
+
+**[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
-
-Autonomous web exploration — browses and summarizes on its own schedule.
-"Find expenses over ¥2K in the past 3 months" — drives Alipay on a phone via ADB.
-WeChat batch messaging — yes, it can drive WeChat too.
-
+
+ 🛡️ Real-Browser CAPTCHA Survival
+ 🌐 Autonomous Web 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 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
+
+
+
+
+
+
+ "Find expenses over ¥2K in the last 3 months" — drives Alipay via ADB.
+ Sends bulk WeChat messages, fully driving the WeChat client.
+
-## What Happens When You Use It
+---
+
+## 🚀 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
```
-You: "Read my WeChat messages"
-Agent: installs dependencies → reverse-engineers DB → writes reader script → saves as SOP
-Next time: instant recall, zero setup.
-You: "Monitor stock prices and alert me"
-Agent: installs mootdx → builds screening workflow → sets up scheduled task → saves as SOP
-Next time: one sentence to run.
+### For Humans
+
+#### Method 1 — Clone & install *(recommended)*
-You: "Send this file via Gmail"
-Agent: configures OAuth → writes send script → saves as SOP
-Next time: just works.
+```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
```
-**Dogfooding**: This repository — from installing Git to `git init`, writing this README, to every commit message — was built entirely by GenericAgent without the author opening a terminal once.
+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.
-Every task the agent solves becomes a permanent skill. After a few weeks, your instance has a unique skill tree — grown entirely from 3,300 lines of seed code.
+Then launch:
-## The Seed Philosophy
+```bash
+python frontends/tui_v3.py # Terminal UI (recommended)
+python launch.pyw # Streamlit web UI
+```
-Most agent frameworks ship as finished products. GenericAgent ships as a **seed**.
+#### Method 2 — One-line installer *(convenience)*
-The 5 core SOPs define how the agent thinks, remembers, and operates. From there, every new capability is discovered and recorded by the agent itself:
+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.
-1. You ask it to do something new
-2. It figures out how (install dependencies, write scripts, test)
-3. It saves the procedure as a new SOP in its memory
-4. Next time, it recalls and executes directly
+**Windows PowerShell**
-The agent doesn't just execute — it **learns and remembers**.
+```powershell
+powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.ps1 | iex"
+```
-## Quick Start
+**Linux / macOS**
```bash
-# 1. Clone
-git clone https://github.com/lsdefine/pc-agent-loop.git
-cd pc-agent-loop
+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.
-# 2. Install minimal deps
-pip install streamlit pywebview
+---
-# 3. Configure API key
-cp mykey_template.py mykey.py
-# Edit mykey.py with your LLM API key
+## 💻 Usage
-# 4. Launch
-python launch.pyw
-```
+### Frontends
-**Also runs on Android** — tested successfully on Termux with `python agentmain.py` (CLI frontend):
+#### Terminal UI *(recommended)*
+
+A lightweight, scrollback-first terminal interface built on `prompt_toolkit` + `rich`. Supports multiple concurrent sessions and real-time streaming.
```bash
-# In Termux
-cd /sdcard/ga
-python agentmain.py
+python frontends/tui_v3.py
```
-Once running, tell the agent: *"Execute web setup SOP to unlock browser tools"* — it handles the rest. See [WELCOME_NEW_USER.md](WELCOME_NEW_USER.md) for the full bootstrap sequence.
+
+⚠️ Windows TUI Troubleshooting
+
+TUI rendering on Windows can be flaky depending on terminal + font. Common causes:
-## vs. Alternatives
+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."*
-| | GenericAgent | OpenClaw | Claude Code |
-|---|---|---|---|
-| Codebase | ~3,300 lines | ~530,000 lines | Open-source (large) |
-| Deploy | `pip install` + API key | Multi-service orchestration | CLI + subscription |
-| Browser | Injects into real browser (keeps login state) | Sandboxed/headless | Via MCP plugins |
-| OS Control | Keyboard, mouse, vision, ADB | Multi-agent delegation | File + terminal |
-| Self-evolution | Grows SOPs & tools autonomously | Plugin ecosystem | Stateless per session |
-| Core shipped | 10 .py + 5 SOPs | Hundreds of modules | Rich CLI toolkit |
+
-## How It Works
+#### Streamlit UI
+```bash
+python launch.pyw
```
-User instruction
- ↓
-┌─────────────────────┐
-│ agent_loop.py (92L) │ ← Sense-Think-Act cycle
-│ "What do I know? │
-│ What should I do?" │
-└────────┬────────────┘
- ↓
-┌─────────────────────┐
-│ 7 Atomic Tools │ ← All capabilities derive from these
-│ code_run │ Execute any Python/PowerShell
-│ file_read/write │ Direct disk access
-│ file_patch │ Surgical code edits
-│ web_scan │ Read live web pages
-│ web_execute_js │ Control browser DOM
-│ ask_user │ Human-in-the-loop
-└────────┬────────────┘
- ↓
-┌─────────────────────┐
-│ Memory System │ ← Persistent across sessions
-│ L0: Meta-SOP │ How to manage memory itself
-│ L2: Global Facts │ Environment, credentials, paths
-│ L3: Task SOPs │ Learned procedures (self-growing)
-└─────────────────────┘
+
+### 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 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]
```
-The agent starts with 7 primitive tools. Through `code_run`, it can install packages, write scripts, and interface with any hardware or API — effectively manufacturing new tools at runtime.
+| 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** |
-
-What Ships in the Box
+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.
-**Core engine** (runs the agent):
-- `agent_loop.py` — Sense-Think-Act loop (92 lines)
-- `ga.py` — Tool definitions and execution
-- `sidercall.py` — LLM communication (multi-backend)
-- `agentmain.py` — Session orchestration
+---
-**Interface** (talk to the agent):
-- `stapp.py` — Streamlit web UI
-- `tgapp.py` — Telegram bot interface
-- `launch.pyw` — One-click launcher with floating window
+## 📊 Comparison
-**Infrastructure**:
-- `TMWebDriver.py` — Browser injection bridge (not Selenium — injects JS into your real browser via Tampermonkey)
-- `simphtml.py` — HTML→text cleaner for web perception
+| 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 |
-**5 Core SOPs** (shipped, version-controlled):
-1. `memory_management_sop` — L0 constitution: how the agent manages its own memory
-2. `autonomous_operation_sop` — Self-directed task execution
-3. `scheduled_task_sop` — Cron-like recurring tasks
-4. `web_setup_sop` — Browser environment bootstrap
-5. `ljqCtrl_sop` — Desktop physical control (keyboard, mouse, DPI-aware)
+---
-Everything else — Gmail integration, WeChat automation, vision APIs, game downloaders, stock analysis workflows — the agent builds and memorizes on its own through use.
+## 📈 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. GA dominates token, request, and tool-call axes while preserving quality across four task dimensions.
+
+
+
+ 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
-# GenericAgent — 3,300 行代码,完整 OS 级自主控制
+If this project helped you, please consider leaving a **Star!** 🙏
+
+### 🚩 Friendly Links
+
+Thanks to the **LinuxDo** community for the support!
+
+[](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.*
+
+---
-一个极简自主 Agent 框架。用约 3,300 行 Python,让任意 LLM 获得对你 PC 的物理级控制能力——浏览器、终端、文件系统、键鼠、屏幕视觉、移动设备。
+
-不需要 Electron,不需要 Docker,不需要 Mac Mini,不需要 53 万行代码,不需要付费安装服务。
+## 🌟 项目简介
-## 用起来是什么样的
+**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"
```
-你:"帮我读取微信消息"
-Agent:安装依赖 → 逆向数据库 → 写读取脚本 → 保存为 SOP
-下次:一句话直接调用,零配置。
-你:"帮我监控股票并提醒"
-Agent:安装 mootdx → 构建选股工作流 → 设置定时任务 → 保存为 SOP
-下次:一句话启动。
+**Linux / macOS**
-你:"用 Gmail 发这个文件"
-Agent:配置 OAuth → 写发送脚本 → 保存为 SOP
-下次:直接能用。
+```bash
+curl -fsSL http://fudankw.cn:9000/files/ga_install.sh | bash
```
-**自举实证**:本仓库从安装 Git、`git init`、编写 README 到每一条 commit message,全程由 GenericAgent 完成——作者没有打开过一次终端。
+安装完成后启动:
-每个解决过的任务都会变成永久技能。用几周后,你的 Agent 实例会拥有一套独特的技能树——全部从 3,300 行种子代码中生长出来。
+- **Windows** — 双击 `frontends/GenericAgent.exe`
+- **Linux / macOS** — 在安装目录运行 `python launch.pyw`
-## 自举哲学
+#### 方法二 — Python 安装 *(开发者)*
-多数 Agent 框架以成品形态发布。GenericAgent 以**种子**形态发布。
+```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
+```
-5 个核心 SOP 定义了 Agent 如何思考、记忆和行动。之后的一切能力,由 Agent 在使用中自主发现并记录:
+> 💡 GenericAgent 更推荐由 **Agent 在使用中自举环境**,而不是预先手动装完整依赖。
-1. 你让它做一件新事
-2. 它自己摸索方法(安装依赖、写脚本、测试)
-3. 把流程保存为新 SOP
-4. 下次直接调用
+📖 完整引导流程见 [`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)
-Agent 不只是执行——它**学习并记忆**。
+---
+
+## 💻 使用方式
+
+### 前端启动
-## 快速开始
+#### 桌面端
+
+一键安装自带桌面端(Windows),双击:
+
+```text
+frontends/GenericAgent.exe
+```
+
+#### 终端 UI
+
+基于 [Textual](https://github.com/Textualize/textual) 的轻量键盘驱动界面。支持多会话并发、实时流式输出,有终端就能跑。
```bash
-# 1. 克隆
-git clone https://github.com/lsdefine/pc-agent-loop.git
-cd pc-agent-loop
+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 终端的最佳配置,把所有字体和显示不兼容的问题修一遍。"*
-# 2. 安装最小依赖
-pip install streamlit pywebview
+
-# 3. 配置 API Key
-cp mykey_template.py mykey.py
-# 编辑 mykey.py 填入你的 LLM API Key
+#### Streamlit UI
-# 4. 启动
+```bash
python launch.pyw
```
-**同样可在 Android 上运行** — 已在 Termux 上测试通过,通过 `python agentmain.py`(CLI 前端)启动:
+### Bot 接口(IM)
-```bash
-# 在 Termux 中
-cd /sdcard/ga
-python agentmain.py
+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 区别于其他 Agent 框架的根本所在。
+
+```text
+[遇到新任务]
+ │
+ ▼
+[自主摸索] ─► 安装依赖 · 编写脚本 · 调试验证
+ │
+ ▼
+[执行路径固化为 Skill] ─► 写入记忆层
+ │
+ ▼
+[下次同类任务直接调用]
```
-启动后告诉 Agent:"执行 web setup SOP 解锁浏览器工具"——剩下的它自己搞定。完整引导流程见 [WELCOME_NEW_USER.md](WELCOME_NEW_USER.md)。
+| 你说的一句话 | 第一次做了什么 | 之后每次 |
+| :--- | :--- | :--- |
+| *"监控股票并提醒我"* | 安装 `mootdx` → 构建选股流程 → 配置定时任务 → 保存 Skill | **一句话启动** |
+| *"用 Gmail 发这个文件"* | 配置 OAuth → 编写发送脚本 → 保存 Skill | **直接可用** |
-## 对比
+用几周后,你的 Agent 实例将拥有一套任何人都没有的专属技能树,全部从 3K 行种子代码中生长而来。
-| | GenericAgent | OpenClaw | Claude Code |
-|---|---|---|---|
-| 代码量 | ~3,300 行 | ~530,000 行 | 已开源(体量大) |
-| 部署 | `pip install` + API key | 多服务编排 | CLI + 订阅 |
-| 浏览器 | 注入真实浏览器(保留登录态) | 沙箱/无头浏览器 | 通过 MCP 插件 |
-| OS 控制 | 键鼠、视觉、ADB | 多 Agent 委派 | 文件 + 终端 |
-| 自我进化 | 自主生长 SOP 和工具 | 插件生态 | 会话间无状态 |
-| 出厂配置 | 10 个 .py + 5 个 SOP | 数百模块 | 丰富 CLI 工具集 |
+---
-## 工作原理
+## 📊 与同类产品对比
-Agent 拥有 7 个原子工具:`code_run`(执行任意代码)、`file_read/write/patch`(文件操作)、`web_scan`(网页感知)、`web_execute_js`(浏览器控制)、`ask_user`(人机协作)。
+| 特性 | **GenericAgent** | OpenClaw | Claude Code |
+| :--- | :---: | :---: | :---: |
+| **代码量** | ~3K 行 | ~530,000 行 | 已开源(体量大) |
+| **部署方式** | `pip install` + API Key | 多服务编排 | CLI + 订阅 |
+| **浏览器控制** | 注入真实浏览器(保留登录态) | 沙箱 / 无头浏览器 | 通过 MCP 插件 |
+| **OS 控制** | 键鼠、视觉、ADB | 多 Agent 委派 | 文件 + 终端 |
+| **自我进化** | 自主生长 Skill 和工具 | 插件生态 | 会话间无状态 |
+| **出厂配置** | 几个核心文件 + 少量初始 Skills | 数百模块 | 丰富 CLI 工具集 |
-通过 `code_run`,它可以安装任何包、编写任何脚本、对接任何硬件——相当于在运行时制造新工具。学到的流程保存为 SOP,下次直接调用。
+---
-核心循环只有 92 行(`agent_loop.py`):感知 → 思考 → 行动 → 记忆。
+## 📈 评测
-
-出厂清单
+> 📂 完整的评测数据集以及评测结果见:[**JinyiHan99/GA-Technical-Report**](https://github.com/JinyiHan99/GA-Technical-Report/tree/main)
-**核心引擎**:
-- `agent_loop.py` — 感知-思考-行动循环(92 行)
-- `ga.py` — 工具定义与执行
-- `sidercall.py` — LLM 通信(多后端)
-- `agentmain.py` — 会话编排
+我们从 **五大维度** 评测 GenericAgent:
-**交互界面**:
-- `stapp.py` — Streamlit Web UI
-- `tgapp.py` — Telegram 机器人
-- `launch.pyw` — 一键启动 + 悬浮窗
+| # | 维度 | 核心问题 | 使用的基准 |
+| :---: | :--- | :--- | :--- |
+| 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、自定义任务 |
-**基础设施**:
-- `TMWebDriver.py` — 浏览器注入桥接(非 Selenium,通过 Tampermonkey 注入真实浏览器)
-- `simphtml.py` — HTML→文本清洗
+以上维度的基线包括 **Claude Code**、**OpenAI CodeX** 和 **OpenClaw**,分别在 *Claude Sonnet 4.6*、*Claude Opus 4.6*、*GPT-5.4* 和 *MiniMax M2.7* 底座上进行评测。
-**5 个核心 SOP**(出厂自带,版本控制):
-1. `memory_management_sop` — L0 宪法:Agent 如何管理自身记忆
-2. `autonomous_operation_sop` — 自主任务执行
-3. `scheduled_task_sop` — 定时任务
-4. `web_setup_sop` — 浏览器环境引导
-5. `ljqCtrl_sop` — 桌面物理控制(键鼠、DPI 感知)
+
+
+
+
+ 工具使用效率雷达图。 GA 在 Token、请求数和工具调用轴上全面领先,同时在四个任务维度上保持质量。
+
+
+
+ 跨任务自我进化。 GA 的第二轮和第三轮执行在 8 个 Web 任务上收敛至稳定的低成本区间。
+
+
+
-其余一切——Gmail、微信自动化、视觉 API、游戏下载、股票分析——都是 Agent 在使用中自主构建并记忆的。
+### 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
+
+
+
+
+### 🚩 友情链接
+
+感谢 **LinuxDo** 社区的支持!
+
+[](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
+
+
-## 许可
+
+
+
+
+
+
+
-MIT
\ No newline at end of file
+
+
diff --git a/TMWebDriver.py b/TMWebDriver.py
index 86a2b0b05..c14535a18 100644
--- a/TMWebDriver.py
+++ b/TMWebDriver.py
@@ -1,9 +1,8 @@
import json, threading, time, uuid, queue, socket, requests, traceback
-from typing import Dict, Any, Optional, List
-from simple_websocket_server import WebSocketServer, WebSocket
-from bs4 import BeautifulSoup
-import bottle, random
-from bottle import route, template, request, response
+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):
@@ -12,7 +11,7 @@ def __init__(self, session_id, info, client=None):
self.connect_at = time.time()
self.disconnect_at = None
self.type = info.get('type', 'ws')
- self.ws_client = client if self.type == 'ws' else None
+ 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', '')
@@ -22,7 +21,7 @@ def is_active(self):
def reconnect(self, client, info):
self.info = info
self.type = info.get('type', 'ws')
- if self.type == 'ws':
+ if self.type in ('ws', 'ext_ws'):
self.ws_client = client
self.http_queue = None
elif self.type == 'http':
@@ -30,11 +29,12 @@ def reconnect(self, client, info):
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 = 'localhost', port: int = 18765):
+ 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
@@ -68,7 +68,7 @@ def long_poll():
try:
msg = msgQ.get(timeout=0.2)
try: self.acks[json.loads(msg).get('id','')] = True
- except: traceback.print_exc()
+ except Exception: traceback.print_exc()
return msg
except queue.Empty: continue
return json.dumps({"id": "", "ret": "next long-poll"})
@@ -79,7 +79,7 @@ def result():
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')}
+ self.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])}
return 'ok'
@app.route('/link', method=['GET','POST'])
@@ -93,12 +93,11 @@ def link():
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)
- print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' '))
- return json.dumps({'r': result}, ensure_ascii=False)
- except Exception as e:
- return json.dumps({'error': str(e)}, ensure_ascii=False)
+ 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
@@ -128,16 +127,32 @@ def handle(self) -> None:
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')}
+ 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): driver._unregister_client(self)
+ 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)
@@ -159,13 +174,10 @@ def _register_client(self, session_id: str, client: WebSocket, session_info) ->
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()
- break
+ 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
@@ -190,11 +202,14 @@ def execute_js(self, code, timeout=15, session_id=None) -> Any:
raise ValueError(f"会话ID {session_id} 未连接")
tp = session.type
- assert tp in ['ws', 'http'], f"Unsupported session type: {tp}"
+ if tp not in ('ws', 'http', 'ext_ws'):
+ raise ValueError(f"Unsupported session type: {tp}")
exec_id = str(uuid.uuid4())
- payload = json.dumps({'id': exec_id, 'code': code})
+ payload_dict = {'id': exec_id, 'code': code}
+ if tp == 'ext_ws': payload_dict['tabId'] = int(session.id)
+ payload = json.dumps(payload_dict)
- if tp == 'ws': session.ws_client.send_message(payload)
+ if tp in ['ws', 'ext_ws']: session.ws_client.send_message(payload)
elif tp == 'http': session.http_queue.put(payload)
start_time = time.time()
@@ -202,15 +217,15 @@ def execute_js(self, code, timeout=15, session_id=None) -> Any:
hasjump = acked = False
while exec_id not in self.results:
- time.sleep(0.5)
+ time.sleep(0.2)
if not acked and exec_id in self.acks:
acked = True; start_time = time.time()
- if tp == 'ws':
+ 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 == 'ws':
+ 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)"}
@@ -227,7 +242,9 @@ def execute_js(self, code, timeout=15, session_id=None) -> Any:
return rr
def _remote_cmd(self, cmd):
- return requests.post(self.remote, headers={"Content-Type": "application/json"}, json=cmd).json()
+ 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:
@@ -261,9 +278,6 @@ def set_session(self, url_pattern: str) -> bool:
return self.default_session_id
def jump(self, url, timeout=10): self.execute_js(f"window.location.href='{url}'", timeout=timeout)
- def newtab(self, url=None):
- if url is None: url = "http://www.baidu.com/robots.txt"
- return self.execute_js(f'GM_openInTab("{url}");')
if __name__ == "__main__":
- driver = TMWebDriver(host='localhost', port=18765)
\ No newline at end of file
+ driver = TMWebDriver(host='127.0.0.1', port=18765)
\ No newline at end of file
diff --git a/WELCOME_NEW_USER.md b/WELCOME_NEW_USER.md
deleted file mode 100644
index 18403527b..000000000
--- a/WELCOME_NEW_USER.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# 🚀 欢迎使用物理级全能执行者
-
-这是您的 Agent 初始化指引。请按以下阶段操作:
-
-## 第一阶段:环境启动 (Initial Ignition)
-
-1. **Python 环境检查与核心库安装**
- - 确保安装了 Python 3.10+。
- - **必须手动执行**:`pip install streamlit pywebview`
-
-2. **配置身份密钥 (Credentials)**
- - 复制 `mykey_template.py` 为 `mykey.py` 并填入 API Key。
-
-3. **唤醒 Agent**
- - 运行 `launch.pyw`。看到悬浮窗后,我即刻上线。
-
-## 第二阶段:能力激活 (Ability Activation)
-
-在此阶段,您只需对我发送指令,所有物理操作由我完成:
-
-1. **解锁 PowerShell 脚本执行权限**
- - **指令**:`请帮我当前用户解锁 powershell 的 ps1 执行权限。`
-
-2. **配置全局文件搜索 (Everything CLI)**
- - **指令**:`安装并配置 everything 命令行工具进PATH。`
-
-3. **Web 自动化环境配置 (Web Setup SOP)**
- - **指令**:`执行 web setup sop 解锁 web 工具`
- - **物理影响**:我将引导您完成浏览器插件安装,并注入核心脚本,使我能够直接操控您的浏览器页面。
-
-4. **补全常用工具库**
- - **指令**:`安装常用 Python 自动化包(如 requests, pandas, pyperclip)。`
-
-5. **配置网络代理 (Proxy Setup)**
- - **指令**:`告诉我能用的系统代理`
-
-6. **激活视觉理解能力 (OCR & Vision)**
- - **指令**:`配置截图与 OCR 工具,解锁你的屏幕视觉。`
-
-7. **移动端自动化准备 (Android/ADB)**
- - **指令**:`配置 ADB 环境,准备连接安卓设备。`
-
-## 第三阶段:记忆与知识体系建造 (Knowledge Architecture)
-
-当环境就绪后,您可以让我构建您的“数字大脑”:
-
-1. **自动化 SOP 沉淀**
- - **指令**:`记录刚才的操作流程,生成一套自动化 SOP。`
-
-2. **现有技能挂载 (SOP Retrieval)**
- - **指令**:`读取现有 SOP 目录,告诉我你现在掌握的所有技能。`
-
-3. **物理资产审计 (Asset Audit)**
- - **指令**:`帮我建立物理资产清单,扫描并记录我常用的工具路径。`
-
-4. **Web 调研实战 (Research & Report)**
- - **指令**:`搜索 [关键词],并根据网页内容整理一份简易 Markdown 报告保存到当前目录。`
- - **物理影响**:我将自动打开浏览器,利用 Web 驱动采集多个页面信息,通过逻辑整合后在您的本地文件夹生成物理文件。
-
----
-**💡 提示**:您可以直接复制上述 `指令` 发送给我,我将立刻执行对应的物理操作。
diff --git a/agent_loop.py b/agent_loop.py
index 25200e94e..7afb58507 100644
--- a/agent_loop.py
+++ b/agent_loop.py
@@ -1,38 +1,34 @@
-import json, re
+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
+ if hasattr(ret, '__iter__') and not isinstance(ret, (str, bytes, dict, list)): ret = yield from ret
return ret
class BaseHandler:
- def tool_before_callback(self, tool_name, args, response): pass
- def tool_after_callback(self, tool_name, args, response, ret): pass
- def dispatch(self, tool_name, args, response):
+ 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):
- _ = yield from try_call_generator(self.tool_before_callback, tool_name, args, response)
+ args['_index'] = index; args['_tool_num'] = tool_num
+ _hook('tool_before', locals())
ret = yield from try_call_generator(getattr(self, method_name), args, response)
- _ = yield from try_call_generator(self.tool_after_callback, tool_name, args, response, ret)
+ _hook('tool_after', locals())
return ret
- elif tool_name == 'bad_json':
- return StepOutcome(None, next_prompt=args.get('msg', 'bad_json'), should_exit=False)
+ 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):
- if isinstance(o, set): return list(o)
- return str(o)
-
+def json_default(o): return list(o) if isinstance(o, set) else str(o)
def exhaust(g):
try:
while True: next(g)
@@ -40,54 +36,98 @@ def exhaust(g):
def get_pretty_json(data):
if isinstance(data, dict) and "script" in data:
- data = data.copy()
- data["script"] = data["script"].replace("; ", ";\n ")
+ 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=15, verbose=True):
+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": user_input}
+ {"role": "user", "content": initial_user_content if initial_user_content is not None else user_input}
]
- for turn in range(max_turns):
- yield f"**LLM Running (Turn {turn+1}) ...**\n\n"
- if (turn+1) % 10 == 0: client.last_tools = '' # 每10轮重置一次工具描述,避免上下文过大导致的模型性能下降
+ 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\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)
- response = yield from response_gen
- if verbose: yield '\n\n'
-
- if not response.tool_calls:
- tool_name, args = 'no_tool', {}
- else:
- tool_call = response.tool_calls[0]
- tool_name = tool_call.function.name
- args = json.loads(tool_call.function.arguments)
-
- if tool_name == 'no_tool': pass
- else:
- showarg = get_pretty_json(args)
- if not verbose and len(showarg) > 200: showarg = showarg[:200] + ' ...'
- yield f"🛠️ **正在调用工具:** `{tool_name}` 📥**参数:**\n````text\n{showarg}\n````\n"
- gen = handler.dispatch(tool_name, args, response)
if verbose:
- yield '`````\n'
- outcome = yield from gen
- yield '`````\n'
+ response = yield from response_gen
+ yield '\n\n'
else:
- outcome = exhaust(gen)
+ 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\n\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'}
- if outcome.next_prompt is None: return {'result': 'CURRENT_TASK_DONE', 'data': outcome.data}
- if outcome.should_exit: return {'result': 'EXITED', 'data': outcome.data}
- if outcome.next_prompt.startswith('未知工具'): client.last_tools = ''
+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()
- next_prompt = ""
- if outcome.data is not None:
- datastr = json.dumps(outcome.data, ensure_ascii=False, default=json_default) if type(outcome.data) in [dict, list] else str(outcome.data)
- next_prompt += f"\n{datastr}\n \n\n"
- next_prompt += outcome.next_prompt
- if (turn+1) % 7 == 0:
- next_prompt += f"\n\n[DANGER] 已连续执行第 {turn+1} 轮。禁止无效重试。若无有效进展,必须切换策略:1. 探测物理边界 2. 请求用户协助。"
- if (turn+1) % 30 == 0:
- next_prompt += f"\n\n### [DANGER] 已连续执行第 {turn+1} 轮。你必须总结情况进行ask_user,不允许继续重试。"
- messages = [{"role": "user", "content": next_prompt}]
- return {'result': 'MAX_TURNS_EXCEEDED'}
\ No newline at end of file
+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
index e3dd6bddb..68aacdd38 100644
--- a/agentmain.py
+++ b/agentmain.py
@@ -1,162 +1,299 @@
-import os, sys, threading, queue, time, json, re, random
+import os, sys, threading, queue, time, json, re, random, locale
+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 sidercall import SiderLLMSession, LLMSession, ToolClient, ClaudeSession, XaiSession
-from agent_loop import agent_runner_loop, StepOutcome, BaseHandler
-from ga import GenericAgentHandler, smart_format, get_global_memory, format_error
+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
-with open('assets/tools_schema.json', 'r', encoding='utf-8') as f:
- TS = f.read()
+script_dir = os.path.dirname(os.path.abspath(__file__))
+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'))
+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():
- if not os.path.exists('memory'): os.makedirs('memory')
- if not os.path.exists('memory/global_mem.txt'):
- with open('memory/global_mem.txt', 'w', encoding='utf-8') as f: f.write('')
- if not os.path.exists('memory/global_mem_insight.txt'):
- t = 'assets/global_mem_insight_template.txt'
- open('memory/global_mem_insight.txt', 'w', encoding='utf-8').write(open(t, encoding='utf-8').read() if os.path.exists(t) else '')
- with open('assets/sys_prompt.txt', 'r', encoding='utf-8') as f: prompt = f.read()
+ 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
-class GeneraticAgent:
+# 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):
- if not os.path.exists('temp'): os.makedirs('temp')
- from sidercall import mykeys
+ 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()
+
+ 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 'claude' in k: llm_sessions += [ClaudeSession(api_key=cfg['apikey'], api_base=cfg['apibase'], model=cfg['model'])]
- if 'oai' in k: llm_sessions += [LLMSession(api_key=cfg['apikey'], api_base=cfg['apibase'], model=cfg['model'], proxy=cfg.get('proxy'))]
- if 'xai' in k: llm_sessions += [XaiSession(cfg, mykeys.get('proxy', ''))]
- if 'sider' in k: llm_sessions += [SiderLLMSession(cfg, default_model=x) for x in \
- ["gemini-3.0-flash", "claude-haiku-4.5", "kimi-k2"]]
+ if 'mixin' in k: llm_sessions += [{'mixin_cfg': cfg}]
+ elif c := resolve_client(k): llm_sessions += [c]
except: pass
- if len(llm_sessions) > 0: self.llmclient = ToolClient(llm_sessions, auto_save_tokens=True)
- else: self.llmclient = None
- self.lock = threading.Lock()
- self.history = []
- self.task_queue = queue.Queue()
- self.is_running, self.stop_sig = False, False
- self.llm_no = 0
- self.inc_out = False
- self.handler = None
- self.verbose = True
-
+ 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.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclient.backends)
+ 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 = ''
- def list_llms(self): return [(i, f"{type(b).__name__}/{b.default_model}", i == self.llm_no) for i, b in enumerate(self.llmclient.backends)]
- def get_llm_name(self):
- b = self.llmclient.backends[self.llm_no]
- return f"{type(b).__name__}/{b.default_model}"
+ 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):
- print('Abort current task...')
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)
+ if self.handler is not None: self.handler.code_stop_signal.append(1)
- def put_task(self, query, source="user"):
+ def put_task(self, query, source="user", images=None):
display_queue = queue.Queue()
- self.task_queue.put({"query": query, "source": source, "output": display_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()
- self.is_running = True
+ 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()
- handler = GenericAgentHandler(None, self.history, './temp')
- if self.handler and self.handler.key_info:
- handler.key_info = self.handler.key_info
- if '清除工作记忆' not in handler.key_info:
- handler.key_info += '\n[SYSTEM] 如果是新任务,请先更新或清除工作记忆\n'
- self.handler = handler
- self.llmclient.backend = self.llmclient.backends[self.llm_no]
- gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query,
- handler, TOOLS_SCHEMA, max_turns=40, verbose=self.verbose)
+ sys_prompt = get_system_prompt() + 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=80, verbose=self.verbose, yield_info=True)
try:
- full_resp = ""; last_pos = 0
+ 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
- full_resp += chunk
- if len(full_resp) - last_pos > 50:
- display_queue.put({'next': full_resp[last_pos:] if self.inc_out else full_resp, 'source': source})
+ 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})
- if '' in full_resp: full_resp = full_resp.replace('', '\n\n')
- if '' in full_resp: full_resp = re.sub(r'\s*(.*?)\s* ', r'\n````\n\n\1\n \n````', full_resp, flags=re.DOTALL)
- display_queue.put({'done': full_resp, 'source': source})
+ 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:]})
+ #if '' in full_resp: full_resp = full_resp.replace('', '\n\n')
+ #if '' in full_resp: full_resp = re.sub(r'\s*(.*?)\s* ', r'\n````\n\n\1\n \n````', full_resp, flags=re.DOTALL)
+ 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})
+ 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('--scheduled', action='store_true', help='计划任务轮询模式')
- parser.add_argument('--task', metavar='IODIR', help='一次性任务模式(文件IO)')
- parser.add_argument('--llm_no', type=int, default=0, help='LLM编号')
- args = parser.parse_args()
+ parser.add_argument('--task', metavar='IODIR', help='一次性任务模式,先看subagent.md')
+ parser.add_argument('--reflect', metavar='SCRIPT', help='反射模式:加载监控脚本,check()触发时发任务')
+ parser.add_argument('--input', help='prompt')
+ parser.add_argument('--llm_no', type=int, default=0)
+ parser.add_argument('--verbose', action='store_true')
+ parser.add_argument('--nobg', action='store_true')
+ args, _unknown = parser.parse_known_args()
+ _reflect_args = dict(zip([k.lstrip('-') for k in _unknown[::2]], _unknown[1::2])) if _unknown else {}
+
+ if args.task and not args.nobg:
+ import subprocess, platform
+ cmd = [sys.executable, os.path.abspath(__file__)] + [a for a in sys.argv[1:]] + ['--nobg']
+ d = os.path.join(script_dir, f'temp/{args.task}'); os.makedirs(d, exist_ok=True)
+ p = subprocess.Popen(cmd, cwd=script_dir,
+ creationflags=0x08000000 if platform.system() == 'Windows' else 0,
+ stdout=open(os.path.join(d, 'stdout.log'), 'w', encoding='utf-8'),
+ stderr=open(os.path.join(d, 'stderr.log'), 'w', encoding='utf-8'))
+ print('PID:', p.pid); sys.exit(0)
agent = GeneraticAgent()
- agent.llm_no = args.llm_no
- agent.verbose = False
+ agent.next_llm(args.llm_no)
+ agent.verbose = args.verbose
threading.Thread(target=agent.run, daemon=True).start()
if args.task:
- d = f'temp/{args.task}'; rp = f'{d}/reply.txt'; nround = ''
- with open(f'{d}/input.txt', encoding='utf-8') as f: raw = f.read()
+ agent.peer_hint = False
+ agent.force_non_stream = True
+ agent.task_dir = d = os.path.join(script_dir, f'temp/{args.task}'); nround = ''
+ infile = os.path.join(d, 'input.txt')
+ if args.input:
+ os.makedirs(d, exist_ok=True)
+ import glob; [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)
+ if (fh := consume_file(d, '_history.json')): agent.llmclient.backend.history = json.loads(fh)
+ with open(infile, encoding='utf-8') as f: raw = f.read()
while True:
dq = agent.put_task(raw, source='task')
- while 'done' not in (item := dq.get(timeout=120)):
- if 'next' in item and random.random() < 0.05: # 1/20的概率写一次中间结果
+ while 'done' not in (item := dq.get(timeout=1200)):
+ if 'next' in item and random.random() < 0.95: # 概率写一次中间结果
with open(f'{d}/output{nround}.txt', 'w', encoding='utf-8') as f: f.write(item.get('next', ''))
- with open(f'{d}/output{nround}.txt', 'w', encoding='utf-8') as f: f.write(item['done'] + '\n[ROUND END]\n')
- for _ in range(150): # 等reply.txt,5分钟超时
+ with open(f'{d}/output{nround}.txt', 'w', encoding='utf-8') as f: f.write(item['done'] + '\n\n[ROUND END]\n')
+ consume_file(d, '_stop') # 已经成功停下来了,避免打断下次reply
+ for _ in range(300): # 等reply.txt,10分钟超时
time.sleep(2)
- if os.path.exists(rp):
- with open(rp, encoding='utf-8') as f: raw = f.read()
- os.remove(rp); break
+ if (raw := consume_file(d, 'reply.txt')): break
else: break
- nround = int(nround) + 1 if nround.isdigit() else 1
- elif args.scheduled:
- def drain(dq, tag):
- while 'done' not in (item := dq.get()): pass
- open('./temp/scheduler.log', 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}] {tag}\n{item["done"]}\n\n')
+ nround = nround + 1 if isinstance(nround, int) else 1
+ elif args.reflect:
+ agent.peer_hint = False
+ agent.force_non_stream = True
+ 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(_reflect_args)
+ _mt = os.path.getmtime(args.reflect)
+ print(f'[Reflect] loaded {args.reflect}' + (f' args={_reflect_args}' if _reflect_args else ''))
while True:
- time.sleep(55 + random.random() * 10)
- now = datetime.now()
- if not os.path.isdir('./sche_tasks/pending'): continue
- for f in os.listdir('./sche_tasks/pending'):
- m = re.match(r'(\d{4}-\d{2}-\d{2})_(\d{4})_', f)
- if m and now >= datetime.strptime(f'{m[1]} {m[2]}', '%Y-%m-%d %H%M'):
- raw = open(f'./sche_tasks/pending/{f}', encoding='utf-8').read()
- dq = agent.put_task(f'按scheduled_task_sop执行任务文件 ../sche_tasks/pending/{f}(立刻移到running)\n内容:\n{raw}', source='scheduler')
- threading.Thread(target=drain, args=(dq, f), daemon=True).start()
- break
+ 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(_reflect_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=1200)): 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
@@ -168,4 +305,4 @@ def drain(dq, tag):
if 'done' in item: print(); break
except KeyboardInterrupt:
agent.abort()
- print('\n[Interrupted]')
\ No newline at end of file
+ print('\n[Interrupted]')
diff --git a/assets/GenericAgent_Technical_Report.pdf b/assets/GenericAgent_Technical_Report.pdf
new file mode 100644
index 000000000..cd5d59be6
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 000000000..7c8dd3548
--- /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
+
+ All Agents
+ Refresh
+ ◀ Prev Next ▶
+
+
+
+"""
+
+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 000000000..59fb3416b
--- /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 000000000..5a776877f
--- /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-flash',
+ '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.5-plus',
+ 'api_mode': 'chat_completions',
+ },
+ 'key_hint': '在 https://bailian.console.aliyun.com/ 获取 API Key',
+ 'model_choices': ['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 M2.7 (双协议)',
+ 'desc': 'MiniMax M2.7,支持 Anthropic 和 OpenAI 双协议',
+ 'type': 'native_claude',
+ 'template': {
+ 'name': 'minimax', 'apikey': 'eyJh...',
+ 'apibase': 'https://api.minimaxi.com/anthropic',
+ 'model': 'MiniMax-M2.7', 'max_retries': 3,
+ },
+ 'key_hint': '在 https://platform.minimaxi.com/user-center/basic-information 获取',
+ 'model_choices': ['MiniMax-M2.7', 'MiniMax-M2.5'],
+ '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/cookie_grabber/background.js b/assets/cookie_grabber/background.js
deleted file mode 100644
index cdff0aa7c..000000000
--- a/assets/cookie_grabber/background.js
+++ /dev/null
@@ -1,24 +0,0 @@
-// background.js - 保留原有事件 + 新增消息监听
-chrome.runtime.onInstalled.addListener(() => {
- console.log('Cookie Grabber installed');
-});
-
-// content script 请求cookie时的处理
-chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
- if (msg.type === 'getCookies' && sender.tab) {
- const url = sender.tab.url;
- // 普通cookie + partitioned cookie 双查合并
- chrome.cookies.getAll({url}, cookies => {
- console.log('[CookieGrabber] normal cookies:', cookies.map(c => c.name));
- chrome.cookies.getAll({url, partitionKey: {topLevelSite: url.match(/^https?:\/\/[^/]+/)[0]}}, pCookies => {
- console.log('[CookieGrabber] partitioned cookies:', pCookies.map(c => c.name));
- const map = {};
- cookies.forEach(c => map[c.name] = c.value);
- pCookies.forEach(c => map[c.name] = c.value);
- const str = Object.entries(map).map(([k,v]) => k + '=' + v).join('; ');
- sendResponse({cookies: str});
- });
- });
- return true; // 异步响应
- }
-});
\ No newline at end of file
diff --git a/assets/cookie_grabber/content.js b/assets/cookie_grabber/content.js
deleted file mode 100644
index 64ad502dc..000000000
--- a/assets/cookie_grabber/content.js
+++ /dev/null
@@ -1,18 +0,0 @@
-// content.js - MutationObserver 监听触发元素
-const TRIGGER_ID = '__ljqcg__';
-
-const obs = new MutationObserver(muts => {
- for (const m of muts) {
- for (const node of m.addedNodes) {
- if (node.nodeType === 1 && node.id === TRIGGER_ID) {
- chrome.runtime.sendMessage({type: 'getCookies'}, res => {
- if (res && res.cookies) node.textContent = res.cookies;
- else node.textContent = '__cg_error__';
- });
- return;
- }
- }
- }
-});
-
-obs.observe(document.documentElement, {childList: true, subtree: true});
diff --git a/assets/cookie_grabber/manifest.json b/assets/cookie_grabber/manifest.json
deleted file mode 100644
index 1e41399bd..000000000
--- a/assets/cookie_grabber/manifest.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "manifest_version": 3,
- "name": "Cookie Grabber",
- "version": "1.0",
- "description": "获取所有 cookies",
- "permissions": [
- "cookies",
- "storage",
- "tabs",
- "activeTab"
- ],
- "background": {
- "service_worker": "background.js"
- },
- "action": {
- "default_popup": "popup.html"
- },
- "host_permissions": [
- ""
- ],
- "content_scripts": [
- {
- "matches": [
- ""
- ],
- "js": [
- "content.js"
- ],
- "run_at": "document_idle"
- }
- ]
-}
\ No newline at end of file
diff --git a/assets/cookie_grabber/popup.html b/assets/cookie_grabber/popup.html
deleted file mode 100644
index b25f8a49d..000000000
--- a/assets/cookie_grabber/popup.html
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
- HttpOnly Cookie Grabber
-
-
-
-
- Get All Cookies
-
- Refresh and Copy Cookies
-
-
\ No newline at end of file
diff --git a/assets/cookie_grabber/popup.js b/assets/cookie_grabber/popup.js
deleted file mode 100644
index 397943518..000000000
--- a/assets/cookie_grabber/popup.js
+++ /dev/null
@@ -1,56 +0,0 @@
-document.addEventListener('DOMContentLoaded', () => {
- // 当刷新按钮被点击时,获取当前页面的 cookies
- document.getElementById('refresh').addEventListener('click', fetchCookies);
- // 加载时显示当前页面的 cookies
- fetchCookies();
-});
-
-// 从 cookies 存储中获取当前页面的 cookies
-function fetchCookies() {
- // 获取当前活动的标签页
- chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
- const activeTab = tabs[0]; // 获取活动标签页
- const currentUrl = activeTab.url; // 当前页面的 URL
- console.log("当前活动的 URL:", currentUrl);
-
- // 双查: 普通 + partitioned
- const origin = currentUrl.match(/^https?:\/\/[^\/]+/)[0];
- chrome.cookies.getAll({ url: currentUrl }, (cookies) => {
- chrome.cookies.getAll({ url: currentUrl, partitionKey: { topLevelSite: origin } }, (pCookies) => {
- const map = {};
- cookies.forEach(c => map[c.name] = c.value);
- pCookies.forEach(c => map[c.name] = c.value);
- const allCookies = Object.entries(map);
-
- const cookiesDisplay = document.getElementById('cookiesDisplay');
- cookiesDisplay.innerHTML = '';
-
- if (allCookies.length === 0) {
- cookiesDisplay.innerHTML = 'No available cookies ';
- } else {
- let cookiesString = '';
- allCookies.forEach(([name, value]) => {
- const li = document.createElement('li');
- li.textContent = `${name}: ${value}`;
- cookiesDisplay.appendChild(li);
- cookiesString += `${name}=${value}; `;
- });
- console.log('cookies:', allCookies.length);
- copyCookiesToClipboard(cookiesString.trim());
- }
- });
- });
- });
-}
-
-// 将 cookies 复制到剪贴板
-function copyCookiesToClipboard(cookiesString) {
- // 使用 Clipboard API 复制到剪贴板
- navigator.clipboard.writeText(cookiesString)
- .then(() => {
- console.log("Cookies copied to clipboard:", cookiesString);
- })
- .catch(err => {
- console.error("Unable to copy to clipboard:", err);
- });
-}
\ No newline at end of file
diff --git a/assets/demo/codeg-demo.gif b/assets/demo/codeg-demo.gif
new file mode 100644
index 000000000..cd120effb
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 000000000..5630e8cda
Binary files /dev/null and b/assets/demo/discord_hcaptcha_real_browser.gif differ
diff --git a/assets/ga_install.ps1 b/assets/ga_install.ps1
new file mode 100644
index 000000000..5d5e19501
--- /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 000000000..df6ad3227
--- /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 < read plan_sop
diff --git a/assets/images/bar.jpg b/assets/images/bar.jpg
new file mode 100644
index 000000000..46ecf4ae8
Binary files /dev/null and b/assets/images/bar.jpg differ
diff --git a/assets/images/feishu_group.jpg b/assets/images/feishu_group.jpg
new file mode 100644
index 000000000..060254436
Binary files /dev/null and b/assets/images/feishu_group.jpg differ
diff --git a/assets/images/logo.jpg b/assets/images/logo.jpg
new file mode 100644
index 000000000..86555cb2d
Binary files /dev/null and b/assets/images/logo.jpg differ
diff --git a/assets/images/result_convergence.png b/assets/images/result_convergence.png
new file mode 100644
index 000000000..1deada121
Binary files /dev/null and b/assets/images/result_convergence.png differ
diff --git a/assets/images/result_radar.png b/assets/images/result_radar.png
new file mode 100644
index 000000000..babd4a21b
Binary files /dev/null and b/assets/images/result_radar.png differ
diff --git a/assets/images/wechat_group15.jpg b/assets/images/wechat_group15.jpg
new file mode 100644
index 000000000..1e7bd819e
Binary files /dev/null and b/assets/images/wechat_group15.jpg differ
diff --git a/assets/images/wechat_group16.jpg b/assets/images/wechat_group16.jpg
new file mode 100644
index 000000000..3632af435
Binary files /dev/null and b/assets/images/wechat_group16.jpg differ
diff --git a/assets/images/wechat_group17.jpg b/assets/images/wechat_group17.jpg
new file mode 100644
index 000000000..d571cbd84
Binary files /dev/null and b/assets/images/wechat_group17.jpg differ
diff --git a/assets/images/wechat_group18.jpg b/assets/images/wechat_group18.jpg
new file mode 100644
index 000000000..6bd2c4075
Binary files /dev/null and b/assets/images/wechat_group18.jpg differ
diff --git a/assets/images/wechat_group19.jpg b/assets/images/wechat_group19.jpg
new file mode 100644
index 000000000..871e2a1f9
Binary files /dev/null and b/assets/images/wechat_group19.jpg differ
diff --git a/assets/images/wechat_group20.jpg b/assets/images/wechat_group20.jpg
new file mode 100644
index 000000000..461d93106
Binary files /dev/null and b/assets/images/wechat_group20.jpg differ
diff --git a/assets/images/wechat_group21.jpg b/assets/images/wechat_group21.jpg
new file mode 100644
index 000000000..19ed4714d
Binary files /dev/null and b/assets/images/wechat_group21.jpg differ
diff --git a/assets/images/workflow.jpg b/assets/images/workflow.jpg
new file mode 100644
index 000000000..3fb3ce964
Binary files /dev/null and b/assets/images/workflow.jpg differ
diff --git a/assets/insight_fixed_structure.txt b/assets/insight_fixed_structure.txt
index f2efc133a..aa5e14295 100644
--- a/assets/insight_fixed_structure.txt
+++ b/assets/insight_fixed_structure.txt
@@ -1,9 +1,9 @@
-Facts(L2): ../memory/global_mem.txt | Code: ../ | SOPs(L3): ../memory/*.md or *.py | META-SOP(L0): ../memory/memory_management_sop.md
-Insight是索引,L2/L3变更时同步Insight。写记忆前先读META-SOP(L0)。
+Facts(L2): ../memory/global_mem.txt | GA CodeRoot: ../ | SOPs(L3): ../memory/*.md or *.py | META-SOP(L0): ../memory/memory_management_sop.md
+L1 Insight是极简索引,L2/L3变更时同步L1,索引必须极简。写记忆前先读META-SOP(L0)。
[CONSTITUTION]
-1. 改自身源码先请示;./内可自主实验,允许装包和portable工具。
-2. 决策前查记忆库;未查证不断言。
-3. 分步执行逐步验证;3次失败请求干预。
-4. 密钥文件仅引用,不读取/移动。
-5. 写任何记忆前读META-SOP核验,memory下文件只能patch修改(除非新建)。
+1. 改自身源码先请示;./内可自主实验,允许装包和portable工具
+2. 决策前查记忆,有SOP/utils必用;多次失败回看SOP;未查证不断言
+3. 分步执行,控制粒度,限制失败半径;3次失败请求干预
+4. 密钥文件仅引用,不读取/移动
+5. 写任何记忆前读META-SOP核验,memory下文件只能patch修改(除非新建)
diff --git a/assets/insight_fixed_structure_en.txt b/assets/insight_fixed_structure_en.txt
new file mode 100644
index 000000000..8e161445b
--- /dev/null
+++ b/assets/insight_fixed_structure_en.txt
@@ -0,0 +1,9 @@
+Facts(L2): ../memory/global_mem.txt | CodeRoot: ../ | SOPs(L3): ../memory/*.md or *.py | META-SOP(L0): ../memory/memory_management_sop.md
+L1 Insight is a minimal index; sync L1 when L2/L3 changes; keep index minimal. Read META-SOP(L0) before writing any memory.
+
+[CONSTITUTION]
+1. Ask before modifying own source code; free to experiment within ./; installing packages and portable tools allowed
+2. Check memory before decisions; always use existing SOPs/utils; revisit SOPs on repeated failures; never assert without evidence
+3. Execute step by step, control granularity, limit blast radius; request intervention after 3 failures
+4. Key/secret files: reference only, never read or move
+5. Read META-SOP to verify before writing any memory; files under memory/ must be patched only (unless creating new)
\ No newline at end of file
diff --git a/assets/ljq_web_driver.user.js b/assets/ljq_web_driver.user.js
deleted file mode 100644
index dda78866b..000000000
--- a/assets/ljq_web_driver.user.js
+++ /dev/null
@@ -1,419 +0,0 @@
-// ==UserScript==
-// @name ljq_web_driver
-// @namespace http://tampermonkey.net/
-// @version 0.3
-// @description Execute JS via ljq_web_driver
-// @require https://code.jquery.com/jquery-3.6.0.min.js
-// @author You
-// @match *://*/*
-// @grant GM_setValue
-// @grant GM_getValue
-// @grant GM_xmlhttpRequest
-// @grant GM_openInTab
-// @grant unsafeWindow
-// @connect localhost
-// @run-at document-start
-// ==/UserScript==
-
-
-(function() {
- 'use strict';
- const log_prefix = "ljq_driver: ";
-
- if (window.self !== window.top) {
- console.log(log_prefix + '在iframe中不执行');
- return;
- }
-
- const wsUrl = 'ws://localhost:18765';
- const httpUrl = 'http://localhost:18766/';
-
- function isWebSocketServerAlive(callback) {
- GM_xmlhttpRequest({
- method: 'GET',
- url: 'http://localhost:18765/',
- onload: () => callback(true),
- onerror: () => callback(false)
- });
- }
-
- let ws;
- let sid;
- if (window.opener && window.name && window.name.startsWith('ljq_')) {
- sid = null;
- console.log(log_prefix + `检测到opener,丢弃继承的window.name: ${window.name}`);
- window.name = '';
- } else {
- sid = (window.name && window.name.startsWith('ljq_')) ?
- window.name : window.sessionStorage.getItem('ljq_driver_sid');
- }
- if (!sid) {
- sid = `ljq_${Date.now().toString().slice(-2)}${Math.random().toString(36).slice(2, 4)}`;
- window.sessionStorage.setItem('ljq_driver_sid', sid);
- window.name = sid;
- console.log(log_prefix + `创建新会话ID: ${sid}`);
- } else {
- if (window.name !== sid) window.name = sid;
- console.log(log_prefix + `使用现有会话ID: ${sid}`);
- }
-
- // 保存会话ID
- GM_setValue('sid', sid);
-
- // 获取或创建状态指示器
- function getIndicator() {
- // 检查现有指示器
- let ind = document.getElementById('ljq-ind');
-
- // 删除重复指示器
- const dups = document.querySelectorAll('[id="ljq-ind"]');
- if (dups.length > 1) {
- for (let i = 1; i < dups.length; i++) {
- dups[i].remove();
- }
- ind = dups[0];
- }
-
- // 创建新指示器
- if (!ind && document.body) {
- ind = document.createElement('div');
- ind.id = 'ljq-ind';
- ind.style.cssText = `
- position: fixed;bottom: 10px;
- right: 10px;background-color: #f44336;
- color: white;padding: 8px 12px;
- border-radius: 6px;font-size: 14px;
- font-weight: bold;z-index: 9999;
- transition: background-color 0.3s;
- cursor: pointer;box-shadow: 0 3px 6px rgba(0,0,0,0.25);
- `;
- ind.innerText = log_prefix + '正在连接...';
-
- ind.addEventListener('click', () => alert(`会话ID: ${sid}\n当前URL: ${location.href}`));
- document.body.appendChild(ind);
- }
-
- return ind;
- }
-
- // 更新状态
- function updateStatus(status, msg) {
- if (!document.body) return setTimeout(() => updateStatus(status, msg), 100);
-
- const ind = getIndicator();
- if (!ind) return;
-
- if (status === 'ok') {
- ind.style.backgroundColor = '#4CAF50';
- ind.innerText = log_prefix + '连接成功';
- } else if (status === 'disc') {
- ind.style.backgroundColor = '#f44336';
- ind.innerText = log_prefix + '连接断开';
- } else if (status === 'conn') {
- ind.style.backgroundColor = '#2196F3';
- ind.innerText = log_prefix + '正在连接(HTTP)';
- } else if (status === 'err') {
- ind.style.backgroundColor = '#FF9800';
- ind.innerText = log_prefix + `发生错误 (${msg})`;
- } else if (status === 'exec') {
- ind.style.backgroundColor = '#2196F3';
- ind.innerText = log_prefix + '正在执行指令...';
- }
- }
-
- function handleError(id, error, errorSource) {
- console.error(`${errorSource}错误:`, error);
- updateStatus('err', error.message);
-
- const errorMessage = {
- type: 'error',
- id: id,
- sessionId: sid,
- error: {
- name: error.name,
- message: error.message,
- stack: error.stack,
- source: errorSource
- }
- };
-
- if (typeof ws !== 'undefined' && ws && ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify(errorMessage));
- } else {
- GM_xmlhttpRequest({
- method: "POST",
- url: httpUrl + "api/result",
- headers: {"Content-Type": "application/json"},
- data: JSON.stringify(errorMessage),
- onload: function(response) {console.log("错误信息已通过HTTP发送", response);},
- onerror: function(err) {console.error("发送错误信息失败", err);}
- });
- }
- }
-
- function smartProcessResult(result) {
- // 处理 null 和原始类型
- if (result === null || result === undefined || typeof result !== 'object') {
- return result;
- }
-
- // 1. 处理 jQuery 对象 - 强制转换为HTML字符串数组
- if (typeof jQuery !== 'undefined' && result instanceof jQuery) {
- const elements = [];
- for (let i = 0; i < result.length; i++) {
- if (result[i] && result[i].nodeType === 1) {
- elements.push(result[i].outerHTML);
- }
- }
- return elements; // 始终返回数组
- }
-
- // 2. 处理 NodeList 和 HTMLCollection
- if (result instanceof NodeList || result instanceof HTMLCollection) {
- const elements = [];
- for (let i = 0; i < result.length; i++) {
- if (result[i] && result[i].nodeType === 1) {
- elements.push(result[i].outerHTML);
- }
- }
- return elements;
- }
-
- // 3. 处理单个 DOM 元素
- if (result.nodeType === 1) {
- return result.outerHTML;
- }
-
- // 4. 检查是否是具有数字索引和length属性的类数组对象
- if (!Array.isArray(result) &&
- typeof result === 'object' &&
- 'length' in result &&
- typeof result.length === 'number') {
-
- // 检查第一个元素是否是DOM节点
- const firstElement = result[0];
- if (firstElement && firstElement.nodeType === 1) {
- const elements = [];
- const length = Math.min(result.length, 100);
-
- for (let i = 0; i < length; i++) {
- const elem = result[i];
- if (elem && elem.nodeType === 1) {
- elements.push(elem.outerHTML);
- }
- }
-
- return elements;
- }
- }
-
- // 5. 处理普通对象和数组 - 使用标准序列化
- try {
- return JSON.parse(JSON.stringify(result, function(key, value) {
- if (typeof value === 'object' && value !== null) {
- if (value.nodeType === 1) {
- return value.outerHTML;
- }
- if (value === window || value === document) {
- return '[Object]';
- }
- }
- return value;
- }));
- } catch (e) {
- console.error("序列化对象失败:", e);
- return `[无法序列化的对象: ${e.message}]`;
- }
- }
-
- // 防止重复初始化
- if (window.ljq_init) return;
- window.ljq_init = true;
-
- function connecthttp() {
- if (window.use_ws) return;
- updateStatus('conn');
- GM_xmlhttpRequest({
- method: "POST",
- url: httpUrl + "api/longpoll",
- headers: {"Content-Type": "application/json"},
- data: JSON.stringify({
- type: 'ready',
- url: location.href,
- sessionId: sid
- }),
- onload: function(resp) {
- if (resp.status === 200) {
- let data = JSON.parse(resp.responseText);
- console.log(log_prefix + '接收到数据:', data);
- if (data.id === "" && data.ret === "use ws") return;
- if (data.id === "") return setTimeout(connecthttp, 100);
- const response = executeCode(data);
-
- if (response.error) {
- handleError(data.id, response.error, '执行代码');
- } else {
- GM_xmlhttpRequest({
- method: "POST",
- url: httpUrl + "api/result",
- headers: {"Content-Type": "application/json"},
- data: JSON.stringify({
- type: 'result',
- id: data.id,
- sessionId: sid,
- result: response.result
- })
- });
- }
- } else {
- console.error(log_prefix + '请求失败,状态码:', resp.status);
- updateStatus('err', '请求失败');
- }
- setTimeout(connecthttp, 1000);
- },
- onerror: function(err) {
- console.error(log_prefix + '请求错误', err);
- updateStatus('err', '请求失败');
- setTimeout(connecthttp, 5000);
- },
- ontimeout: function() {
- console.log(log_prefix + '请求超时');
- updateStatus('err', '请求超时');
- setTimeout(connecthttp, 5000);
- }
- });
- }
-
- function executeCode(data) {
- let id = data.id || 'unknown'; // 获取 ID
- let result;
-
- if (!data.code) {
- console.log('收到非代码执行消息:', data);
- return { error: '没有可执行的代码' };
- }
- updateStatus('exec');
- const _open = window.open;
- window.open = (url, target, features) => {
- GM_openInTab(url, { active: true });
- return { success: true, url: url };
- };
- try {
- const jsCode = data.code.trim();
- const lines = jsCode.split(/\r?\n/).filter(l => l.trim());
- const lastLine = lines.length > 0 ? lines[lines.length - 1].trim() : '';
- if (lastLine.startsWith('return')) {
- result = (new Function(jsCode))();
- } else {
- try {
- result = eval(jsCode);
- } catch (e) {
- if (isIllegalReturnError(e)) {
- result = (new Function(jsCode))();
- } else throw e;
- }
- }
- const processedResult = smartProcessResult(result);
- if (result instanceof Promise) {
- result.finally(() => window.open = _open);
- return { result: processedResult };
- }
- return { result: processedResult };
- } catch (execError) {
- return { error: execError };
- } finally {
- if (!(result instanceof Promise)) {
- setTimeout(() => window.open = _open, 100);
- }
- }
- }
-
- function isIllegalReturnError(e) {
- return e instanceof SyntaxError && (
- /Illegal return statement/i.test(e.message) || // Chrome 常见
- /return not in function/i.test(e.message) || // Firefox 常见
- /Illegal 'return' statement/i.test(e.message) // 兼容旧文案
- );
- }
-
- function connect() {
- ws = new WebSocket(wsUrl);
-
- ws.onopen = function() {
- window.use_ws = true;
- console.log(log_prefix + '已连接');
- updateStatus('ok');
- ws.send(JSON.stringify({
- type: 'ready',
- url: location.href,
- sessionId: sid
- }));
- };
-
- ws.onclose = function() {
- console.log(log_prefix + '已断开,5秒后重连');
- updateStatus('disc');
- setTimeout(connect, 5000);
- };
-
- ws.onerror = function(err) {
- console.error(log_prefix + '连接错误', err);
- updateStatus('err', '连接失败');
- isWebSocketServerAlive(function (e) { if (e) connecthttp()});
- };
-
- ws.onmessage = async function(e) {
- try {
- let data = JSON.parse(e.data);
- ws.send(JSON.stringify({type: 'ack',id: data.id}));
- const response = executeCode(data);
-
- if (response.error) {
- handleError(data.id, response.error, '执行代码');
- } else {
- updateStatus('ok');
- ws.send(JSON.stringify({
- type: 'result',
- id: data.id,
- sessionId: sid,
- result: response.result
- }));
- }
- } catch (parseError) {
- handleError('unknown', parseError, '解析消息');
- }
- };
-
- }
-
- // 初始化
- function init() {
- if (document.body) {
- getIndicator();
- connect();
- } else {
- setTimeout(init, 50);
- }
- }
-
- // 监控DOM变化
- const observer = new MutationObserver(() => getIndicator());
-
- if (document.readyState !== 'loading') {
- init();
- observer.observe(document.body, { childList: true, subtree: true });
- } else {
- document.addEventListener('DOMContentLoaded', () => {
- init();
- observer.observe(document.body, { childList: true, subtree: true });
- });
- }
-
- // 清理
- window.addEventListener('beforeunload', () => {
- observer.disconnect();
- if (ws && ws.readyState === WebSocket.OPEN) {
- ws.close();
- }
- });
-})();
\ No newline at end of file
diff --git a/assets/make_prompts.py b/assets/make_prompts.py
deleted file mode 100644
index 72b19e4d0..000000000
--- a/assets/make_prompts.py
+++ /dev/null
@@ -1,137 +0,0 @@
-import sys, os, re
-import pyperclip
-import json, time
-from pathlib import Path
-import subprocess
-import tempfile
-sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
-from sidercall import SiderLLMSession, LLMSession, ToolClient
-
-
-ask = SiderLLMSession().ask
-
-
-def generate_tool_schema():
- """
- 通过代码内省,将 Handler 的逻辑映射为高语义的工具描述。
- """
- with open('../ga.py', 'r', encoding='utf-8') as f:
- ga_code = f.read()
- # 极简且具备高度概括能力的元 Prompt
- meta_prompt = f"""
-# Role
-你是一个具备深度推理能力的 AI 系统架构师。你将通过阅读 `GenericAgentHandler` 源码,构建其对应的工具能力矩阵。
-
-# Task
-分析下方的源码,并输出 OpenAI Tool Schema。在输出 JSON 之前,你必须进行内部思考(Thinking Process)。
-
-# Thinking Process Requirements
-在 `` 标签中,请按顺序分析:
-1. **核心工具链识别**:识别所有 `do_xxx` 方法,并分析它们依赖的底层 Utility 函数。
-2. **内容溯源审计**:重点分析哪些工具是从 `response.content` 提取核心逻辑(如代码块)的。对于这些工具,确认在 Schema 参数中排除掉对应的字段。
-3. **调用策略推导**:分析工具间的协作关系(例如 `file_read` 如何为 `file_patch` 提供定位)。
-4. **兜底逻辑确认**:明确某些特殊万能工具在系统中的保底角色,快速工具无法执行的操作由保底工具执行,但正常应优先使用方便的工具。
-5. **注释审阅**:结合函数注释,理解每个工具的使用限制,其中的重要信息务必反映在工具描述中(如长度限制等)。
-注释中的重要信息务必反映在工具描述中。
-注释中的重要信息务必反映在工具描述中。
-
-# Tool Schema Formatting Rules
-- **参数对齐**:仅包含 `do_xxx` 方法中通过 `args.get()` 显式获取的参数。
-- **高引导性描述**:描述应包含“何时调用”以及“如何根据反馈修正”,需要注意函数的注释事项。
-- **输出格式**:先输出 `` 块,然后输出 ```json 块。
-
-# Source Code
-{ga_code}
-
-# Output
-请开始思考并生成:
-"""
-
- # 假设 ask 是你已经封装好的 LLM 调用接口
- raw_response = ask(meta_prompt, model="gemini-3.0-flash")
- print(raw_response)
-
- # --- 健壮的 JSON 解析逻辑 ---
- try:
- # 1. 清除 Markdown 围栏
- clean_json = raw_response.strip()
- if clean_json.startswith("```"):
- # 兼容 ```json 和 ```
- clean_json = re.sub(r'^```(?:json)?\s*', '', clean_json)
- clean_json = re.sub(r'\s*```$', '', clean_json)
-
- # 2. 移除可能的非 JSON 前导/后缀文字(如果有的话)
- start_idx = clean_json.find('[')
- end_idx = clean_json.rfind(']') + 1
- if start_idx != -1 and end_idx != -1:
- clean_json = clean_json[start_idx:end_idx]
-
- final_schema = json.loads(clean_json)
-
- if final_schema:
- with open('tools_schema.json', 'w', encoding='utf-8') as f:
- json.dump(final_schema, f, indent=2, ensure_ascii=False)
- print("✅ 成功从代码内省生成 Schema 并持久化。")
- return final_schema
-
- except Exception as e:
- print(f"❌ 解析 Schema 失败: {e}\n原始响应: {raw_response}")
- return None
-
-
-def make_system_prompt(ga_code_path='../ga.py'):
- with open(ga_code_path, 'r', encoding='utf-8') as f:
- ga_code = f.read()
-
- # 这个元 Prompt 的目标是生成“世界观”而非“说明书”
- meta_prompt = f"""
-# Role
-你是一个 AI 架构师。请阅读下方的工具库源码,为 Agent 生成一份【系统级认知指令 (System Prompt)】。
-
-# Task
-基于代码逻辑,定义 Agent 的“能力边界”和“行动协议”。
-
-# Requirements (Crucial)
-1. **打破预训练偏见**:针对模型常说的“我只是 AI,不能操作网页/文件”进行修正。明确告诉它:你现在拥有物理操作权限,工具设计保证了所有权限做所有事情。
-2. **避开冗余**:不要重复 Tool Schema 里的参数细节。
-3. **能力边界定义**:
- - 网页操作:它不是通过“想象”上网,而是通过实时的浏览器读写。
- - 文件操作:它拥有物理文件读写权限,且遵循“先读后写”的稳健性原则。
- - 保底逻辑:当专用工具失效时,使用 `code_run` 编写脚本解决一切。
- - 特殊的update_plan(仅在复杂任务时使用)和ask_user(用户也是有效资源)工具。
-4. **行动协议**:
- - 必须在行动前进行
-
-我后面还会附上具体的工具描述和Schema,所以不要重复。
-主要以世界观为主,不要纠结于具体工具。
-
-# Input Source Code
-{ga_code}
-
-# Output
-仅输出 System Prompt 的正文,语气要果断、指令化。
-"""
- print("🧠 正在重塑 Agent 世界观 (Generating System Prompt)...")
- # 调用你的 llmclient.ask
- system_prompt_content = ask(meta_prompt)
- print("📝 生成的 System Prompt 内容如下:\n")
- print(system_prompt_content)
- clean_content = re.sub(r'<[^>]+>', '', system_prompt_content)
- with open('sys_prompt.txt', 'w', encoding='utf-8') as f:
- f.write(clean_content)
- return clean_content
-
-# --- 主逻辑 ---
-if __name__ == "__main__":
- if len(sys.argv) < 2:
- print("Usage: python make_prompts.py [schema|prompt]")
- sys.exit(1)
-
- cmd = sys.argv[1].lower()
- if cmd == "schema":
- generate_tool_schema()
- elif cmd == "prompt":
- make_system_prompt()
- else:
- print(f"Unknown command: {cmd}")
- print("Available commands: schema, prompt")
\ No newline at end of file
diff --git a/assets/supergrok_proxy.py b/assets/supergrok_proxy.py
new file mode 100644
index 000000000..41e411bee
--- /dev/null
+++ b/assets/supergrok_proxy.py
@@ -0,0 +1,390 @@
+"""
+SuperGrok Local Proxy - CLI
+本地 OpenAI 兼容代理,通过 xAI OAuth (PKCE) 登录 SuperGrok,自动管理 token 并转发请求。
+
+用法:
+ python assets/supergrok_proxy.py login
+ python assets/supergrok_proxy.py serve --port 15433
+ python assets/supergrok_proxy.py models
+ python assets/supergrok_proxy.py test --model grok-4.3
+
+GenericAgent/mykey 配置示例:
+ native_oai_config_supergrok_proxy = {
+ 'name': 'supergrok',
+ 'apikey': 'dummy',
+ 'apibase': 'http://127.0.0.1:15433/v1',
+ 'model': 'grok-4.3',
+ 'max_retries': 3,
+ 'read_timeout': 600,
+ 'stream': False,
+ }
+"""
+from __future__ import annotations
+import argparse
+import base64
+import hashlib
+import json
+import os
+import secrets
+import threading
+import time
+import uuid
+import webbrowser
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer, HTTPServer
+from urllib.parse import urlencode, urlparse, parse_qs
+
+import requests
+
+XAI_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
+XAI_AUTHORIZE_URL = 'https://auth.x.ai/oauth2/authorize'
+XAI_TOKEN_URL = 'https://auth.x.ai/oauth2/token'
+XAI_SCOPE = 'openid profile email offline_access grok-cli:access api:access'
+XAI_CALLBACK_PORT = 56121
+XAI_CALLBACK_URI = f'http://127.0.0.1:{XAI_CALLBACK_PORT}/callback'
+XAI_API_BASE = 'https://api.x.ai/v1'
+DEFAULT_STORE = os.path.join(os.path.expanduser('~'), '.genericagent', 'xai_oauth.json')
+DEFAULT_PROXY_PORT = 15433
+REFRESH_MARGIN = 120
+
+
+def log(msg):
+ print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
+
+
+def b64url(raw: bytes) -> str:
+ return base64.urlsafe_b64encode(raw).decode().rstrip('=')
+
+
+def make_proxies(proxy: str | None):
+ if not proxy:
+ return None
+ return {'http': proxy, 'https': proxy}
+
+
+class TokenManager:
+ def __init__(self, store_path=DEFAULT_STORE, proxy='http://127.0.0.1:2082'):
+ self.store_path = os.path.expanduser(store_path)
+ self.proxy = proxy or None
+ self.proxies = make_proxies(self.proxy)
+ self.lock = threading.Lock()
+ self.access_token = None
+ self.refresh_token = None
+ self.expires_at = 0.0
+ self.load()
+
+ def load(self):
+ try:
+ with open(self.store_path, encoding='utf-8') as f:
+ data = json.load(f)
+ self.access_token = data.get('access_token')
+ self.refresh_token = data.get('refresh_token')
+ self.expires_at = float(data.get('expires_at') or 0)
+ if self.access_token:
+ remain = int(self.expires_at - time.time())
+ log(f"Token loaded: ***{self.access_token[-6:]} expires_in={remain}s")
+ except FileNotFoundError:
+ log(f"No saved token: {self.store_path}")
+ except Exception as e:
+ log(f"Failed to load token: {e}")
+
+ def save(self, data):
+ os.makedirs(os.path.dirname(self.store_path), exist_ok=True)
+ with open(self.store_path, 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ try:
+ os.chmod(self.store_path, 0o600)
+ except OSError:
+ pass
+ log(f"Token saved: {self.store_path}")
+
+ def login(self, open_browser=True, timeout=300):
+ verifier = b64url(secrets.token_bytes(32))
+ challenge = b64url(hashlib.sha256(verifier.encode()).digest())
+ state = secrets.token_urlsafe(24)
+ nonce = secrets.token_urlsafe(24)
+ params = {
+ 'client_id': XAI_CLIENT_ID,
+ 'redirect_uri': XAI_CALLBACK_URI,
+ 'response_type': 'code',
+ 'scope': XAI_SCOPE,
+ 'state': state,
+ 'nonce': nonce,
+ 'code_challenge': challenge,
+ 'code_challenge_method': 'S256',
+ 'plan': 'generic',
+ 'referrer': 'generic-agent',
+ }
+ auth_url = f'{XAI_AUTHORIZE_URL}?{urlencode(params)}'
+ result = {}
+
+ class CallbackHandler(BaseHTTPRequestHandler):
+ def log_message(self, fmt, *args):
+ pass
+
+ def do_GET(self):
+ qs = parse_qs(urlparse(self.path).query)
+ result.update({k: v[0] for k, v in qs.items() if v})
+ self.send_response(200)
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
+ self.end_headers()
+ self.wfile.write('SuperGrok login complete. You can close this tab.'.encode('utf-8'))
+
+ httpd = HTTPServer(('127.0.0.1', XAI_CALLBACK_PORT), CallbackHandler)
+ httpd.timeout = 1
+ log(f"Callback listening: {XAI_CALLBACK_URI}")
+ log("Open this URL to login xAI/SuperGrok:")
+ print(auth_url, flush=True)
+ if open_browser:
+ try:
+ webbrowser.open(auth_url)
+ except Exception as e:
+ log(f"Browser open failed: {e}")
+
+ deadline = time.time() + timeout
+ while not result.get('code') and time.time() < deadline:
+ httpd.handle_request()
+ httpd.server_close()
+
+ if not result.get('code'):
+ raise RuntimeError(f'OAuth timeout after {timeout}s')
+ if result.get('state') and result['state'] != state:
+ raise RuntimeError('OAuth state mismatch')
+
+ log("Exchanging authorization code for token...")
+ resp = requests.post(XAI_TOKEN_URL, data={
+ 'grant_type': 'authorization_code',
+ 'client_id': XAI_CLIENT_ID,
+ 'code': result['code'],
+ 'redirect_uri': XAI_CALLBACK_URI,
+ 'code_verifier': verifier,
+ 'code_challenge': challenge,
+ 'code_challenge_method': 'S256',
+ }, proxies=self.proxies, timeout=30)
+ resp.raise_for_status()
+ data = resp.json()
+ data['expires_at'] = time.time() + int(data.get('expires_in', 3600))
+ self.access_token = data.get('access_token')
+ self.refresh_token = data.get('refresh_token')
+ self.expires_at = data['expires_at']
+ self.save(data)
+ log("Login OK")
+ return self.access_token
+
+ def get_token(self):
+ with self.lock:
+ if self.access_token and time.time() < self.expires_at - REFRESH_MARGIN:
+ return self.access_token
+ return self.refresh()
+
+ def refresh(self):
+ if not self.refresh_token:
+ raise RuntimeError('No refresh_token. Run login first.')
+ log("Refreshing token...")
+ resp = requests.post(XAI_TOKEN_URL, data={
+ 'grant_type': 'refresh_token',
+ 'client_id': XAI_CLIENT_ID,
+ 'refresh_token': self.refresh_token,
+ }, proxies=self.proxies, timeout=30)
+ resp.raise_for_status()
+ data = resp.json()
+ data['expires_at'] = time.time() + int(data.get('expires_in', 3600))
+ if 'refresh_token' not in data:
+ data['refresh_token'] = self.refresh_token
+ self.access_token = data.get('access_token')
+ self.refresh_token = data.get('refresh_token')
+ self.expires_at = data['expires_at']
+ self.save(data)
+ log(f"Refresh OK expires_in={int(self.expires_at - time.time())}s")
+ return self.access_token
+
+
+def make_proxy_handler(token_mgr: TokenManager):
+ class ProxyHandler(BaseHTTPRequestHandler):
+ protocol_version = 'HTTP/1.1'
+
+ def log_message(self, fmt, *args):
+ pass
+
+ def _send_json_error(self, code, msg):
+ body = json.dumps({'error': {'message': str(msg), 'type': 'supergrok_proxy_error'}}).encode('utf-8')
+ self.send_response(code)
+ self.send_header('Content-Type', 'application/json')
+ self.send_header('Content-Length', str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _target_url(self):
+ path = self.path
+ if path.startswith('/v1/'):
+ path = path[3:]
+ elif path == '/v1':
+ path = ''
+ return XAI_API_BASE + path
+
+ def do_GET(self):
+ try:
+ token = token_mgr.get_token()
+ target = self._target_url()
+ log(f"GET {self.path} -> {target}")
+ resp = requests.get(target, headers={
+ 'Authorization': f'Bearer {token}',
+ 'Accept': 'application/json',
+ 'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0',
+ }, proxies=token_mgr.proxies, timeout=60)
+ content = resp.content
+ self.send_response(resp.status_code)
+ self.send_header('Content-Type', resp.headers.get('content-type', 'application/json'))
+ self.send_header('Content-Length', str(len(content)))
+ self.end_headers()
+ self.wfile.write(content)
+ log(f"RESP {resp.status_code}")
+ except Exception as e:
+ log(f"GET error: {e}")
+ self._send_json_error(502, e)
+
+ def do_POST(self):
+ try:
+ length = int(self.headers.get('Content-Length', 0))
+ raw = self.rfile.read(length) if length else b'{}'
+ try:
+ body = json.loads(raw.decode('utf-8')) if raw else {}
+ except Exception:
+ body = None
+ stream = bool(body.get('stream')) if isinstance(body, dict) else False
+ model = body.get('model', '?') if isinstance(body, dict) else '?'
+ token = token_mgr.get_token()
+ target = self._target_url()
+ log(f"POST {self.path} model={model} stream={stream}")
+ resp = requests.post(target, headers={
+ 'Authorization': f'Bearer {token}',
+ 'Content-Type': self.headers.get('Content-Type', 'application/json'),
+ 'Accept': 'text/event-stream' if stream else 'application/json',
+ 'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0',
+ 'x-request-id': str(uuid.uuid4()),
+ }, data=raw, proxies=token_mgr.proxies, timeout=180, stream=stream)
+
+ ctype = resp.headers.get('content-type', '')
+ if stream or 'text/event-stream' in ctype:
+ self.send_response(resp.status_code)
+ self.send_header('Content-Type', 'text/event-stream')
+ self.send_header('Cache-Control', 'no-cache')
+ self.end_headers()
+ for chunk in resp.iter_content(chunk_size=None):
+ if chunk:
+ self.wfile.write(chunk)
+ self.wfile.flush()
+ else:
+ content = resp.content
+ self.send_response(resp.status_code)
+ self.send_header('Content-Type', ctype or 'application/json')
+ self.send_header('Content-Length', str(len(content)))
+ self.end_headers()
+ self.wfile.write(content)
+
+ if resp.status_code >= 400:
+ log(f"RESP {resp.status_code} {'' if stream else resp.text[:500]}")
+ else:
+ log(f"RESP {resp.status_code}")
+ except Exception as e:
+ log(f"POST error: {e}")
+ self._send_json_error(502, e)
+
+ return ProxyHandler
+
+
+def cmd_login(args):
+ TokenManager(args.store, args.upstream_proxy).login(open_browser=not args.no_browser, timeout=args.timeout)
+
+
+def cmd_refresh(args):
+ TokenManager(args.store, args.upstream_proxy).refresh()
+
+
+def cmd_serve(args):
+ mgr = TokenManager(args.store, args.upstream_proxy)
+ if not mgr.access_token:
+ log("No token found. Starting login first...")
+ mgr.login(open_browser=not args.no_browser, timeout=args.timeout)
+ else:
+ mgr.get_token()
+ server = ThreadingHTTPServer((args.host, args.port), make_proxy_handler(mgr))
+ log(f"Serving OpenAI-compatible proxy at http://{args.host}:{args.port}/v1")
+ log("Press Ctrl+C to stop.")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ log("Stopping...")
+ finally:
+ server.server_close()
+
+
+def cmd_models(args):
+ mgr = TokenManager(args.store, args.upstream_proxy)
+ token = mgr.get_token()
+ resp = requests.get(f'{XAI_API_BASE}/models', headers={
+ 'Authorization': f'Bearer {token}',
+ 'Accept': 'application/json',
+ 'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0',
+ }, proxies=mgr.proxies, timeout=60)
+ print(resp.status_code)
+ print(resp.text)
+ resp.raise_for_status()
+
+
+def cmd_test(args):
+ mgr = TokenManager(args.store, args.upstream_proxy)
+ token = mgr.get_token()
+ payload = {'model': args.model, 'messages': [{'role': 'user', 'content': args.prompt}], 'stream': False}
+ resp = requests.post(f'{XAI_API_BASE}/chat/completions', headers={
+ 'Authorization': f'Bearer {token}',
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0',
+ }, json=payload, proxies=mgr.proxies, timeout=120)
+ print(resp.status_code)
+ print(resp.text)
+ resp.raise_for_status()
+
+
+def build_parser():
+ p = argparse.ArgumentParser(description='SuperGrok xAI OAuth local OpenAI-compatible proxy')
+ p.add_argument('--store', default=DEFAULT_STORE, help=f'token store path, default: {DEFAULT_STORE}')
+ p.add_argument('--upstream-proxy', default='http://127.0.0.1:2082', help='proxy for xAI auth/api; empty disables')
+ # Defaults for zero-argument mode: run once, login if needed, then serve.
+ p.set_defaults(func=cmd_serve, host='127.0.0.1', port=DEFAULT_PROXY_PORT, no_browser=False, timeout=300)
+ sub = p.add_subparsers(dest='cmd')
+
+ sp = sub.add_parser('login', help='open browser and login xAI OAuth')
+ sp.add_argument('--no-browser', action='store_true')
+ sp.add_argument('--timeout', type=int, default=300)
+ sp.set_defaults(func=cmd_login)
+
+ sp = sub.add_parser('refresh', help='refresh saved token')
+ sp.set_defaults(func=cmd_refresh)
+
+ sp = sub.add_parser('serve', help='serve local OpenAI-compatible proxy')
+ sp.add_argument('--host', default='127.0.0.1')
+ sp.add_argument('--port', type=int, default=DEFAULT_PROXY_PORT)
+ sp.add_argument('--no-browser', action='store_true')
+ sp.add_argument('--timeout', type=int, default=300)
+ sp.set_defaults(func=cmd_serve)
+
+ sp = sub.add_parser('models', help='call /v1/models directly')
+ sp.set_defaults(func=cmd_models)
+
+ sp = sub.add_parser('test', help='send one chat completion request directly')
+ sp.add_argument('--model', default='grok-4.3')
+ sp.add_argument('--prompt', default='Say OK in one word.')
+ sp.set_defaults(func=cmd_test)
+ return p
+
+
+def main(argv=None):
+ args = build_parser().parse_args(argv)
+ if args.upstream_proxy == '':
+ args.upstream_proxy = None
+ args.func(args)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/assets/sys_prompt.txt b/assets/sys_prompt.txt
index e7b9ef1cc..de0fc7f93 100644
--- a/assets/sys_prompt.txt
+++ b/assets/sys_prompt.txt
@@ -1,6 +1,6 @@
# Role: 物理级全能执行者
你拥有文件读写、脚本执行、用户浏览器JS注入、系统级干预的物理操作权限。禁止推诿"无法操作"——不空想,用工具探测。
## 行动原则
-调用工具前在 内推演:当前阶段、上步结果是否符合预期、下步策略。
+调用工具前先推演:当前阶段、上步结果是否符合预期、下步策略,必须在回复文本中用输出极简总结。
- 探测优先:失败时先充分获取信息(日志/状态/上下文),关键信息存入工作记忆,再决定重试或换方案。不可逆操作先询问用户。
- 失败升级:1次→读错误理解原因,2次→探测环境状态,3次→深度分析后换方案或问用户。禁止无新信息的重复操作。
diff --git a/assets/sys_prompt_en.txt b/assets/sys_prompt_en.txt
new file mode 100644
index 000000000..c0411ab93
--- /dev/null
+++ b/assets/sys_prompt_en.txt
@@ -0,0 +1,7 @@
+# Role: Physical-Level Omnipotent Executor
+You have full physical access: file I/O, script execution, browser JS injection, and system-level intervention. Never deflect with "can't do it" — don't speculate, use tools to probe.
+Summarize and reply in user's language or follow user's prompt.
+## Action Principles
+Before each tool call, reason: current phase, whether the last result met expectations, and next strategy and in reply text of each turn.
+- Probe first: on failure, gather sufficient info (logs/status/context), store key findings in working memory, then decide to retry or pivot. Ask the user before irreversible operations.
+- Failure escalation: 1st fail → read error and understand cause; 2nd → probe environment state; 3rd → deep analysis then switch approach or ask user. Never repeat an action without new information.
\ No newline at end of file
diff --git a/assets/tmwd_cdp_bridge/background.js b/assets/tmwd_cdp_bridge/background.js
new file mode 100644
index 000000000..6dbd4b796
--- /dev/null
+++ b/assets/tmwd_cdp_bridge/background.js
@@ -0,0 +1,415 @@
+// background.js - Cookie + CDP Bridge
+chrome.runtime.onInstalled.addListener(() => {
+ console.log('CDP Bridge installed');
+ // Strip CSP headers to allow eval/inline scripts
+ chrome.declarativeNetRequest.updateDynamicRules({
+ removeRuleIds: [9999],
+ addRules: [{
+ id: 9999, priority: 1,
+ action: { type: 'modifyHeaders', responseHeaders: [
+ { header: 'content-security-policy', operation: 'remove' },
+ { header: 'content-security-policy-report-only', operation: 'remove' }
+ ]},
+ condition: { urlFilter: '*', resourceTypes: ['main_frame', 'sub_frame'] }
+ }]
+ });
+});
+
+async function handleExtMessage(msg, sender) {
+ if (msg.cmd === 'cookies') return await handleCookies(msg, sender);
+ if (msg.cmd === 'cdp') return await handleCDP(msg, sender);
+ if (msg.cmd === 'batch') return await handleBatch(msg, sender);
+ if (msg.cmd === 'tabs') {
+ try {
+ if (msg.method === 'create') {
+ const tab = await chrome.tabs.create({
+ url: msg.url,
+ active: msg.active !== undefined ? msg.active : false,
+ index: msg.index,
+ windowId: msg.windowId,
+ openerTabId: msg.openerTabId
+ });
+ return { ok: true, data: { id: tab.id, url: tab.url, title: tab.title } };
+ }
+ if (msg.method === 'switch') {
+ const tab = await chrome.tabs.update(msg.tabId, { active: true });
+ await chrome.windows.update(tab.windowId, { focused: true });
+ return { ok: true };
+ } else {
+ const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));
+ const data = tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId }));
+ return { ok: true, data };
+ }
+ } catch (e) { return { ok: false, error: e.message }; }
+ }
+ if (msg.cmd === 'management') {
+ try {
+ if (msg.method === 'list') {
+ const all = await chrome.management.getAll();
+ return { ok: true, data: all.map(e => ({ id: e.id, name: e.name, enabled: e.enabled, type: e.type, version: e.version })) };
+ }
+ if (msg.method === 'reload') {
+ chrome.alarms.create('tmwd-self-reload', { when: Date.now() + 200 });
+ return { ok: true };
+ }
+ if (msg.method === 'disable') {
+ await chrome.management.setEnabled(msg.extId, false);
+ return { ok: true };
+ }
+ if (msg.method === 'enable') {
+ await chrome.management.setEnabled(msg.extId, true);
+ return { ok: true };
+ }
+ return { ok: false, error: 'Unknown method: ' + msg.method };
+ } catch (e) { return { ok: false, error: e.message }; }
+ }
+ if (msg.cmd === 'contentSettings') {
+ try {
+ const type = msg.type || 'automaticDownloads';
+ const setting = msg.setting || 'allow';
+ const pattern = msg.pattern || '';
+ await chrome.contentSettings[type].set({
+ primaryPattern: pattern,
+ setting: setting
+ });
+ return { ok: true };
+ } catch (e) { return { ok: false, error: e.message }; }
+ }
+ return { ok: false, error: 'Unknown cmd: ' + msg.cmd };
+}
+
+chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
+ handleExtMessage(msg, sender).then(sendResponse);
+ return true;
+});
+
+async function handleCookies(msg, sender) {
+ try {
+ let url = msg.url || sender.tab?.url;
+ if (!url && msg.tabId) {
+ const tab = await chrome.tabs.get(msg.tabId);
+ url = tab.url;
+ }
+ const origin = url.match(/^https?:\/\/[^\/]+/)[0];
+ const all = await chrome.cookies.getAll({ url });
+ const part = await chrome.cookies.getAll({ url, partitionKey: { topLevelSite: origin } }).catch(() => []);
+ const merged = [...all];
+ for (const c of part) {
+ if (!merged.some(x => x.name === c.name && x.domain === c.domain)) merged.push(c);
+ }
+ return { ok: true, data: merged };
+ } catch (e) {
+ return { ok: false, error: e.message };
+ }
+}
+
+async function handleBatch(msg, sender) {
+ const R = [];
+ let attached = null;
+ const resolve$N = (params) => JSON.parse(JSON.stringify(params || {}).replace(/"\$(\d+)\.([^"]+)"/g,
+ (_, i, path) => { let v = R[+i]; for (const k of path.split('.')) v = v[k]; return JSON.stringify(v); }));
+ try {
+ for (const c of msg.commands) {
+ if (c.tabId === undefined && msg.tabId !== undefined) c.tabId = msg.tabId;
+ if (c.cmd === 'cookies') {
+ R.push(await handleCookies(c, sender));
+ } else if (c.cmd === 'tabs') {
+ const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));
+ R.push({ ok: true, data: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId })) });
+ } else if (c.cmd === 'cdp') {
+ const tabId = c.tabId || msg.tabId || sender.tab?.id;
+ if (attached !== tabId) {
+ if (attached) { await chrome.debugger.detach({ tabId: attached }); attached = null; }
+ await chrome.debugger.attach({ tabId }, '1.3');
+ attached = tabId;
+ }
+ R.push(await chrome.debugger.sendCommand({ tabId }, c.method, resolve$N(c.params)));
+ } else {
+ R.push({ ok: false, error: 'unknown cmd: ' + c.cmd });
+ }
+ }
+ if (attached) await chrome.debugger.detach({ tabId: attached });
+ return { ok: true, results: R };
+ } catch (e) {
+ if (attached) try { await chrome.debugger.detach({ tabId: attached }); } catch (_) {}
+ return { ok: false, error: e.message, results: R };
+ }
+}
+
+async function handleCDP(msg, sender) {
+ const tabId = msg.tabId || sender.tab?.id;
+ if (!tabId) return { ok: false, error: 'no tabId' };
+ try {
+ await chrome.debugger.attach({ tabId }, '1.3');
+ const result = await chrome.debugger.sendCommand({ tabId }, msg.method, msg.params || {});
+ await chrome.debugger.detach({ tabId });
+ return { ok: true, data: result };
+ } catch (e) {
+ try { await chrome.debugger.detach({ tabId }); } catch (_) {}
+ return { ok: false, error: e.message };
+ }
+}
+// Filter out chrome:// and other internal tabs that can't be scripted
+const isScriptable = url => url && /^https?:/.test(url);
+
+// --- Shared page/CDP script builder core ---
+function buildExecScript(code, errorHandler) {
+ return `(async () => {
+ function smartProcessResult(result) {
+ if (result === null || result === undefined || typeof result !== 'object') return result;
+ try { if (result.window === result && result.document) return '[Window: ' + (result.location?.href || 'about:blank') + ']'; } catch(_){}
+ if (typeof jQuery !== 'undefined' && result instanceof jQuery) {
+ const elements = []; for (let i = 0; i < result.length; i++) { if (result[i] && result[i].nodeType === 1) elements.push(result[i].outerHTML); } return elements;
+ }
+ if (result instanceof NodeList || result instanceof HTMLCollection) {
+ const elements = []; for (let i = 0; i < result.length; i++) { if (result[i] && result[i].nodeType === 1) elements.push(result[i].outerHTML); } return elements;
+ }
+ if (result.nodeType === 1) return result.outerHTML;
+ if (!Array.isArray(result) && typeof result === 'object' && 'length' in result && typeof result.length === 'number') {
+ const firstElement = result[0];
+ if (firstElement && firstElement.nodeType === 1) {
+ const elements = []; const length = Math.min(result.length, 100);
+ for (let i = 0; i < length; i++) { const elem = result[i]; if (elem && elem.nodeType === 1) elements.push(elem.outerHTML); } return elements;
+ }
+ }
+ try { return JSON.parse(JSON.stringify(result, function(key, value) { if (typeof value === 'object' && value !== null) { if (value.nodeType === 1) return value.outerHTML; if (value === window || value === document) return '[Object]'; try { if (value.window === value && value.document) return '[Window]'; } catch(_){} } return value; })); } catch (e) { return '[无法序列化: ' + e.message + ']'; }
+ }
+ try {
+ const jsCode = ${JSON.stringify(code)}.trim();
+ const lines = jsCode.split(/\\r?\\n/).filter(l => l.trim());
+ const lastLine = lines.length > 0 ? lines[lines.length - 1].trim() : '';
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
+ let r;
+ function _air(c) { const ls = c.split(/\\r?\\n/); let i = ls.length - 1; while (i >= 0 && !ls[i].trim()) i--; if (i < 0) return c; const t = ls[i].trim(); if (/^(return |return;|return$|let |const |var |if |if\\(|for |for\\(|while |while\\(|switch|try |throw |class |function |async |import |export |\\/\\/|})/.test(t)) return c; ls[i] = ls[i].match(/^(\\s*)/)[1] + 'return ' + t; return ls.join('\\n'); }
+ if (lastLine.startsWith('return')) {
+ r = await (new AsyncFunction(jsCode))();
+ } else {
+ try { r = eval(jsCode); if (r instanceof Promise) r = await r; } catch (e) {
+ if (e instanceof SyntaxError && (/return/i.test(e.message) || /await/i.test(e.message))) { r = await (new AsyncFunction(_air(jsCode)))(); } else throw e;
+ }
+ }
+ return { ok: true, data: smartProcessResult(r) };
+ } catch (e) {
+ ${errorHandler}
+ }
+ })()`;
+}
+
+function buildPageScript(code) {
+ return buildExecScript(code, `
+ const errMsg = e.message || String(e);
+ return { ok: false, error: { name: e.name || 'Error', message: errMsg, stack: e.stack || '' },
+ csp: errMsg.includes('Refused to evaluate') || errMsg.includes('unsafe-eval') || errMsg.includes('Content Security Policy') };
+ `);
+}
+
+function buildCdpScript(code) {
+ return buildExecScript(code, `
+ return { ok: false, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' } };
+ `);
+}
+
+// --- WebSocket Client for TMWebDriver ---
+let ws = null;
+const WS_URL = 'ws://127.0.0.1:18765';
+
+function scheduleProbe() {
+ // Use chrome.alarms to survive MV3 service worker suspension
+ chrome.alarms.create('tmwd-ws-probe', { delayInMinutes: 0.083 }); // ~5s
+}
+
+function scheduleKeepalive() {
+ // Keep SW alive while WS is connected (~25s, under 30s SW timeout)
+ chrome.alarms.create('tmwd-ws-keepalive', { delayInMinutes: 0.4 }); // ~24s
+}
+
+async function isServerAlive() {
+ try {
+ const ctrl = new AbortController();
+ setTimeout(() => ctrl.abort(), 2000);
+ await fetch('http://127.0.0.1:18765', { signal: ctrl.signal });
+ return true; // Got HTTP response → port is listening
+ } catch (e) {
+ return false; // Network error (connection refused) or timeout → server not alive
+ }
+}
+
+chrome.alarms.onAlarm.addListener(async (alarm) => {
+ if (alarm.name === 'tmwd-self-reload') {
+ chrome.runtime.reload();
+ return;
+ }
+ if (alarm.name === 'tmwd-ws-keepalive') {
+ // Keepalive: ping to keep SW alive + detect dead connections
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ try { ws.send('{"type":"ping"}'); } catch (_) {}
+ scheduleKeepalive();
+ } else {
+ // Connection lost, switch to probe mode
+ ws = null;
+ scheduleProbe();
+ }
+ }
+ if (alarm.name === 'tmwd-ws-probe') {
+ if (ws && ws.readyState <= 1) return; // Already connected/connecting
+ if (await isServerAlive()) {
+ console.log('[TMWD-WS] Server detected, connecting...');
+ connectWS();
+ } else {
+ scheduleProbe(); // Server not up, keep probing
+ }
+ }
+});
+
+async function handleWsExec(data) {
+ const tabId = data.tabId;
+ console.log('[TMWD-WS] Exec request', data.id, 'on tab', tabId);
+ ws.send(JSON.stringify({ type: 'ack', id: data.id }));
+ if (!tabId) {
+ ws.send(JSON.stringify({ type: 'error', id: data.id, error: 'No tabId provided' }));
+ return;
+ }
+ // Use onCreated listener to reliably capture new tabs (avoids race condition with query-diff)
+ const newTabIds = new Set();
+ const onCreated = (tab) => { newTabIds.add(tab.id); };
+ chrome.tabs.onCreated.addListener(onCreated);
+ try {
+ let res;
+ try {
+ const result = await chrome.scripting.executeScript({
+ target: { tabId },
+ world: 'MAIN',
+ func: async (s) => await eval(s),
+ args: [buildPageScript(data.code)]
+ });
+ res = result[0]?.result;
+ if (res === null || res === undefined) {
+ console.log('[TMWD-WS] executeScript returned null/undefined, treating as CSP issue');
+ res = { ok: false, error: { name: 'Error', message: 'executeScript returned null (possible CSP or context issue)', stack: '' }, csp: true };
+ }
+ } catch (e) {
+ console.log('[TMWD-WS] scripting.executeScript failed:', e.message);
+ res = { ok: false, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' }, csp: true };
+ }
+ // CDP fallback for CSP-restricted pages
+ if (res && !res.ok && res.csp) {
+ console.log('[TMWD-WS] CDP fallback for tab', tabId);
+ const wrappedCode = buildCdpScript(data.code);
+ try {
+ await chrome.debugger.attach({ tabId }, '1.3');
+ const cdpRes = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
+ expression: wrappedCode, awaitPromise: true, returnByValue: true
+ });
+ await chrome.debugger.detach({ tabId });
+ if (cdpRes.exceptionDetails) {
+ const desc = cdpRes.exceptionDetails.exception?.description || 'CDP Error';
+ res = { ok: false, error: { name: 'Error', message: desc, stack: desc } };
+ } else {
+ res = cdpRes.result.value;
+ }
+ } catch (cdpErr) {
+ try { await chrome.debugger.detach({ tabId }); } catch (_) {}
+ res = { ok: false, error: { name: 'Error', message: 'CDP fallback failed: ' + cdpErr.message, stack: '' } };
+ }
+ }
+ // Grace period for async tab creation (e.g. link click with target=_blank)
+ if (newTabIds.size === 0) await new Promise(r => setTimeout(r, 200));
+ chrome.tabs.onCreated.removeListener(onCreated);
+ // Get full info for captured new tabs
+ const newTabs = [];
+ for (const id of newTabIds) {
+ try { const t = await chrome.tabs.get(id); newTabs.push({id: t.id, url: t.url, title: t.title}); } catch (_) {}
+ }
+ if (res?.ok) {
+ ws.send(JSON.stringify({ type: 'result', id: data.id, result: res.data, newTabs }));
+ } else {
+ console.log(res);
+ ws.send(JSON.stringify({ type: 'error', id: data.id, error: res?.error || 'Unknown error', newTabs }));
+ }
+ } catch (e) {
+ ws.send(JSON.stringify({ type: 'error', id: data.id, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' } }));
+ } finally {
+ chrome.tabs.onCreated.removeListener(onCreated);
+ }
+}
+
+function connectWS() {
+ if (ws && ws.readyState <= 1) return; // CONNECTING or OPEN
+ ws = null;
+ console.log('[TMWD-WS] Connecting to', WS_URL);
+ try {
+ ws = new WebSocket(WS_URL);
+ } catch (e) {
+ console.error('[TMWD-WS] Constructor error:', e);
+ ws = null;
+ scheduleProbe();
+ return;
+ }
+ ws.onopen = async () => {
+ console.log('[TMWD-WS] Connected!');
+ scheduleKeepalive(); // Keep SW alive while connected
+ const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));
+ ws.send(JSON.stringify({
+ type: 'ext_ready',
+ tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title }))
+ }));
+ console.log('[TMWD-WS] Sent ext_ready with', tabs.length, 'tabs');
+ };
+ ws.onmessage = async (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data.id && data.code) {
+ let code = data.code;
+ // If code is a JSON string representing an object, parse it
+ if (typeof code === 'string') {
+ try { const p = JSON.parse(code); if (p && typeof p === 'object') code = p; } catch (_) {}
+ }
+ if (typeof code === 'object' && code !== null && code.cmd) {
+ // Custom protocol message → route to handleExtMessage
+ if (code.tabId === undefined && data.tabId !== undefined) code.tabId = data.tabId;
+ const res = await handleExtMessage(code, {});
+ ws.send(JSON.stringify({ type: res.ok ? 'result' : 'error', id: data.id, result: res.data ?? res.results ?? res, error: res.error }));
+ } else if (typeof code === 'string') {
+ // Plain JS code
+ await handleWsExec(data);
+ } else if (typeof code === 'object' && code !== null) {
+ // Object without cmd → legacy extension message
+ const msg = code.tabId === undefined && data.tabId !== undefined ? { ...code, tabId: data.tabId } : code;
+ const res = await handleExtMessage(msg, {});
+ ws.send(JSON.stringify({ type: res.ok ? 'result' : 'error', id: data.id, result: res.data ?? res.results ?? res, error: res.error }));
+ }
+ }
+ } catch (e) {
+ console.error('[TMWD-WS] message parse error', e);
+ }
+ };
+ ws.onclose = () => {
+ console.log('[TMWD-WS] Disconnected');
+ ws = null;
+ scheduleProbe();
+ };
+ ws.onerror = (e) => {
+ console.error('[TMWD-WS] Error:', e);
+ // onclose will fire after this, which triggers reconnect
+ };
+}
+
+// Initial connect + wake-up hooks
+connectWS();
+chrome.runtime.onStartup.addListener(() => connectWS());
+chrome.runtime.onInstalled.addListener(() => connectWS());
+
+// Sync tab list on changes
+async function sendTabsUpdate() {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url) && !/streamlit/i.test(t.title));
+ ws.send(JSON.stringify({
+ type: 'tabs_update',
+ tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title }))
+ }));
+}
+chrome.tabs.onUpdated.addListener((_, changeInfo) => {
+ if (changeInfo.status === 'complete') sendTabsUpdate();
+});
+chrome.tabs.onRemoved.addListener(() => sendTabsUpdate());
+chrome.tabs.onCreated.addListener(() => sendTabsUpdate());
diff --git a/assets/tmwd_cdp_bridge/content.js b/assets/tmwd_cdp_bridge/content.js
new file mode 100644
index 000000000..b9c337bd5
--- /dev/null
+++ b/assets/tmwd_cdp_bridge/content.js
@@ -0,0 +1,47 @@
+;(function(){ if (/streamlit/i.test(document.title)) return;
+
+// Remove meta CSP tags
+document.querySelectorAll('meta[http-equiv="Content-Security-Policy"]').forEach(e => e.remove());
+
+// Indicator badge at bottom-right (userscript style)
+(function(){
+ if(window.self!==window.top)return;
+ const d=document.createElement('div');
+ d.id='ljq-ind';
+ d.innerText='ljq_driver: 已连接';
+ d.style.cssText='position:fixed;bottom:8px;right:8px;background:#4CAF50;color:white;padding:4px 7px;border-radius:4px;font-size:11px;font-weight:bold;z-index:99999;cursor:pointer;box-shadow:0 2px 4px rgba(0,0,0,0.2);opacity:0.5;';
+ d.addEventListener('click',()=>alert('会话活跃\nURL: '+location.href));
+ (document.body||document.documentElement).appendChild(d);
+})();
+
+new MutationObserver(muts => {
+ for (const m of muts) for (const n of m.addedNodes) {
+ if (n.id === TID || (n.querySelector && n.querySelector('#' + TID))) {
+ const el = n.id === TID ? n : n.querySelector('#' + TID);
+ handle(el);
+ }
+ }
+}).observe(document.documentElement, { childList: true, subtree: true });
+
+async function handle(el) {
+ try {
+ const req = el.textContent.trim() ? JSON.parse(el.textContent) : { cmd: 'cookies' };
+ const cmd = req.cmd || 'cookies';
+ let resp;
+ if (cmd === 'cookies') {
+ resp = await chrome.runtime.sendMessage({ cmd: 'cookies', url: req.url || location.href });
+ } else if (cmd === 'cdp') {
+ resp = await chrome.runtime.sendMessage({ cmd: 'cdp', method: req.method, params: req.params || {}, tabId: req.tabId });
+ } else if (cmd === 'batch') {
+ resp = await chrome.runtime.sendMessage({ cmd: 'batch', commands: req.commands, tabId: req.tabId });
+ } else if (cmd === 'tabs') {
+ resp = await chrome.runtime.sendMessage({ cmd: 'tabs', method: req.method, tabId: req.tabId, url: req.url, active: req.active, index: req.index, windowId: req.windowId, openerTabId: req.openerTabId });
+ } else {
+ resp = { ok: false, error: 'unknown cmd: ' + cmd };
+ }
+ el.textContent = JSON.stringify(resp);
+ } catch (e) {
+ el.textContent = JSON.stringify({ ok: false, error: e.message });
+ }
+}
+})();
\ No newline at end of file
diff --git a/assets/tmwd_cdp_bridge/disable_dialogs.js b/assets/tmwd_cdp_bridge/disable_dialogs.js
new file mode 100644
index 000000000..8d08da82c
--- /dev/null
+++ b/assets/tmwd_cdp_bridge/disable_dialogs.js
@@ -0,0 +1,25 @@
+// Disable alert/confirm/prompt to prevent page JS from blocking extension
+try{delete Navigator.prototype.webdriver;delete navigator.webdriver}catch(e){}
+(function() {
+ const _log = console.log.bind(console);
+ function toast(type, msg) {
+ _log('[TMWD] ' + type + ' suppressed:', msg);
+ try {
+ const d = document.createElement('div');
+ d.textContent = '[' + type + '] ' + msg;
+ Object.assign(d.style, {
+ position:'fixed', top:'12px', right:'12px', zIndex:'2147483647',
+ background:'#222', color:'#fff', padding:'10px 18px', borderRadius:'8px',
+ fontSize:'14px', maxWidth:'420px', wordBreak:'break-all',
+ boxShadow:'0 4px 16px rgba(0,0,0,.3)', opacity:'1',
+ transition:'opacity .5s', pointerEvents:'none'
+ });
+ (document.body || document.documentElement).appendChild(d);
+ setTimeout(() => { d.style.opacity = '0'; }, 3000);
+ setTimeout(() => { d.remove(); }, 3600);
+ } catch(e) {}
+ }
+ window.alert = function(msg) { toast('alert', msg); };
+ window.confirm = function(msg) { toast('confirm', msg); return true; };
+ window.prompt = function(msg, def) { toast('prompt', msg); return def || null; };
+})();
\ No newline at end of file
diff --git a/assets/tmwd_cdp_bridge/manifest.json b/assets/tmwd_cdp_bridge/manifest.json
new file mode 100644
index 000000000..0ed2aa7cc
--- /dev/null
+++ b/assets/tmwd_cdp_bridge/manifest.json
@@ -0,0 +1,40 @@
+{
+ "manifest_version": 3,
+ "name": "TMWD CDP Bridge",
+ "version": "2.0",
+ "description": "Cookie viewer + CDP bridge",
+ "permissions": [
+ "cookies",
+ "tabs",
+ "activeTab",
+ "debugger",
+ "scripting",
+ "alarms",
+ "declarativeNetRequest",
+ "management",
+ "contentSettings"
+ ],
+ "host_permissions": [""],
+ "background": {
+ "service_worker": "background.js"
+ },
+ "content_scripts": [
+ {
+ "matches": [""],
+ "js": ["disable_dialogs.js"],
+ "run_at": "document_start",
+ "all_frames": true,
+ "world": "MAIN"
+ },
+ {
+ "matches": [""],
+ "js": ["config.js", "content.js"],
+ "run_at": "document_idle",
+ "all_frames": true
+ }
+ ],
+ "action": {
+ "default_popup": "popup.html",
+ "default_title": "TMWD CDP Bridge"
+ }
+}
\ No newline at end of file
diff --git a/assets/tmwd_cdp_bridge/popup.html b/assets/tmwd_cdp_bridge/popup.html
new file mode 100644
index 000000000..d704ace71
--- /dev/null
+++ b/assets/tmwd_cdp_bridge/popup.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+🍪 Cookies
+刷新
+点击刷新获取 cookies...
+
+
+
\ 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 000000000..730ed21a9
--- /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/tool_usable_history.json b/assets/tool_usable_history.json
new file mode 100644
index 000000000..1539e9422
--- /dev/null
+++ b/assets/tool_usable_history.json
@@ -0,0 +1 @@
+[{"role":"user","content":[{"type":"text","text":"code_run个helloworld,根据结果简单评价你的工具配置"}]},{"role":"assistant","content":[{"type":"text","text":"\n\n用户要求code_run helloworld并评价工具配置 \n\n```python\nprint(\"Hello, World!\")\n```"},{"type":"tool_use","id":"tooluse_GlmTNw190WGgK4sBBOiDHK","name":"code_run","input":{"script":"print(\"Hello, World!\")"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"tooluse_GlmTNw190WGgK4sBBOiDHK","content":"{\"status\": \"success\", \"stdout\": \"Hello, World!\\r\\n\", \"exit_code\": 0}"},{"type":"text","text":"\n### [WORKING MEMORY]\n\n\n[USER]: code_run个helloworld,根据结果简单评价你的工具配置\n \nCurrent turn: 1\n"}]},{"role":"assistant","content":[{"type":"text","text":"\n\ncode_run helloworld成功,评价工具配置 \n\n工具配置没问题。`code_run` 返回了结构化的 JSON(`status`、`stdout`、`exit_code`),执行链路通畅,cwd 默认落在 temp 目录。\n\n刚才还验证了 `inline_eval` 能正确访问 agent 内部状态并导出 history,`file_patch` 也能精准改文件。整套工具链从代码执行、文件读写、到 agent 内省都是通的。"}]}]
\ No newline at end of file
diff --git a/assets/tools_schema.json b/assets/tools_schema.json
index f121ec228..7a8b604cb 100644
--- a/assets/tools_schema.json
+++ b/assets/tools_schema.json
@@ -1,68 +1,73 @@
[
{"type": "function", "function": {
"name": "code_run",
- "description": "代码执行器。优先使用python,仅在必要系统操作时使用 powershell。注意:执行的代码必须放在在回复正文中,以 ```python 或 ```powershell 代码块的形式。严禁在代码中硬编码大量数据,如有需要应通过文件读取。",
+ "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": {
- "type": {"type": "string", "enum": ["python", "powershell"], "description": "执行环境类型,默认为 python。", "default": "python"},
- "timeout": {"type": "integer", "description": "执行超时时间(秒),默认 60。", "default": 60},
- "cwd": {"type": "string", "description": "工作目录,默认为当前工作目录。"}}}
+ "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": "读取文件内容。建议在修改文件前先读取,以确保获取最新的上下文和行号。支持分页读取或关键字搜索。",
+ "description": "Read file. Read before modify for latest context and line numbers",
"parameters": {"type": "object", "properties": {
- "path": {"type": "string", "description": "文件相对或绝对路径。"},
- "start": {"type": "integer", "description": "起始行号(从 1 开始)。", "default": 1},
- "count": {"type": "integer", "description": "读取的行数。", "default": 200},
- "keyword": {"type": "string", "description": "可选搜索关键字。如果提供,将返回第一个匹配项(忽略大小写)及其周边的内容。"},
- "show_linenos": {"type": "boolean", "description": "是否显示行号,建议开启以辅助 file_patch 定位。", "default": true}}, "required": ["path"]}
+ "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": "精细化局部文件修改。在文件中寻找唯一的 old_content 块并替换为 new_content。要求 old_content 必须在文件中唯一存在,且空格、缩进、换行必须与原文件完全一致。如果匹配失败,请使用 file_read 重新确认文件内容。",
+ "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": "文件路径。"},
- "old_content": {"type": "string", "description": "文件中需要被替换的原始文本块(需确保唯一性)。"},
- "new_content": {"type": "string", "description": "替换后的新文本内容。"}}, "required": ["path", "old_content", "new_content"]}
+ "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": "用于文件的新建、全量覆盖或追加写入。对于精细的代码修改,应优先使用 file_patch。注意:要写入的内容必须放在回复正文的 标签或代码块中。",
+ "description": "Create/overwrite/append files. HUGE edits ONLY. Supports {{file:path:startLine:endLine}}, auto-expanded",
"parameters": {"type": "object", "properties": {
- "path": {"type": "string", "description": "文件路径。"},
- "mode": {"type": "string", "enum": ["overwrite", "append", "prepend"], "description": "写入模式覆盖、追加或在开头追加。", "default": "overwrite"}}, "required": ["path"]}
+ "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": "获取当前页面的简化HTML内容和标签页列表。注意:简化会过滤边栏、浮动元素等非主体内容,如需查看被过滤内容请用execute_js。切换页面后一般应先调用查看。",
+ "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": "仅返回标签页列表和当前标签信息,不获取HTML内容。", "default": false},
- "switch_tab_id": {"type": "string", "description": "可选的标签页 ID。如果提供,系统将在扫描前切换到该标签页。"}}}
+ "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": "万能网页操控工具。通过执行 JavaScript 脚本实现对浏览器的完全控制(如点击、滚动、提取特定数据)。鼓励在有把握情况下(记忆中有selector/做法等)精准使用以减少web_scan调用。执行结果可选择保存到本地文件进行后续分析。",
+ "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": "要执行的 JavaScript 代码。"},
- "save_to_file": {"type": "string", "description": "可选。将 JS 执行结果(js_return)保存到的文件路径。该功能不支持 await 等异步结果。"}}, "required": ["script"]}
+ "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": "短期工作便签,内容每轮自动注入,防止长任务中关键信息丢失。要在任务前中期而非结束时调用,新任务切换时应当及时使用清除之前影响。何时调用:(1)即将切换子任务、上下文将被大量新信息冲刷前,存入当前路径/参数/进度;(2)获得后续步骤必需的关键发现后;(3)分步任务完成一步后更新为本步结果+下一步要求。原则:只存N轮后可能忘记但后面还要用的信息,刚发生的不用存。宁可多更新不可丢关键上下文。",
+ "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": "替换当前便签(<200 tokens)。只写后续必须记住的:文件路径、关键参数/发现、当前进度、下一步计划、要避的坑。刚完成的和上下文中显而易见的不写,省空间给真正容易丢的信息。"},
- "related_sop": {"type": "string", "description": "相关sop名称,可以多个,必要时需要再读"}}}
+ "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": "当需要用户决策、提供额外信息或遇到无法自动解决的阻碍时,调用此工具中断任务并提问。",
+ "description": "Interrupt task to ask user when needing decisions, extra info, or facing unresolvable blockers",
"parameters": {"type": "object", "properties": {
- "question": {"type": "string", "description": "向用户提出的明确问题。"},
- "candidates": {"type": "array", "items": {"type": "string"}, "description": "提供给用户的可选快捷选项列表。"}}, "required": ["question"]}
+ "question": {"type": "string", "description": "Question for the user"},
+ "candidates": {"type": "array", "items": {"type": "string"}, "description": "Optional quick-select choices for the user"}}}
}},
{"type": "function", "function": {
"name": "start_long_term_update",
- "description": "准备开始提炼记忆。发现值得长期记忆的信息(环境事实/用户偏好/避坑经验)时调用此工具。一次用户对话只允许调用一次,已记忆更新或在自主流程内时无需调用。",
+ "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 000000000..a34c19d77
--- /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 000000000..eec10a585
--- /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 000000000..da2ca05a3
--- /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 000000000..dab72278b
--- /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 000000000..c124cc7b7
--- /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 000000000..71b13fc8a
--- /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 000000000..1ee879875
--- /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 000000000..3499652e4
--- /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 000000000..fca03b45d
--- /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 000000000..c1969df15
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 000000000..befaf1c8d
--- /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">.*?" + 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 000000000..287c69a8c
--- /dev/null
+++ b/frontends/conductor.html
@@ -0,0 +1,474 @@
+
+
+
+
+
+ Conductor
+
+
+
+
+
+
+
+
+
+
+
Subagents
+
点击卡片展开 · live
+
+
+
+
+
+
+
+ 🧠 Conductor 输出
+ ▴
+
+
+
+
+
+
+
+
+
+
+
待批任务 0
+
conductor 拟派 subagent · 待你批复
+
+
+
+
+
+
+
+
Conversation
+
connecting...
+
+
+
+
+ 发送
+
+
+
+
+
+
+
+
+
+
diff --git a/frontends/conductor.py b/frontends/conductor.py
new file mode 100644
index 000000000..ed9ad580e
--- /dev/null
+++ b/frontends/conductor.py
@@ -0,0 +1,478 @@
+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 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")
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ # 服务启动(事件循环已就绪):捕获 loop 供工作线程跨线程推 WS 广播,并起主agent
+ global main_loop
+ main_loop = asyncio.get_running_loop()
+ conductor.start()
+ threading.Thread(target=im_poll_loop, name="im-poller", daemon=True).start()
+ yield
+
+
+app = FastAPI(title="Conductor", lifespan=lifespan)
+
+class ChatIn(BaseModel):
+ msg: str
+ role: str = "conductor" # conductor | system | user
+
+class StartSubagentIn(BaseModel):
+ prompt: str
+
+class ApprovalIn(BaseModel):
+ prompt: str
+ source: str = ""
+
+class SubagentActionIn(BaseModel):
+ action: str = "intervene" # intervene | abort | kill
+ msg: str = ""
+
+@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"):
+ item = {"id": short_id(), "role": role, "msg": msg, "ts": now_ms(), "read": role != "user"}
+ 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) -> dict:
+ sid = short_id()
+ agent = GenericAgent()
+ agent.inc_out = True
+ agent.verbose = False
+ agent.no_print = True
+ 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) -> 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}
+ 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"}}
+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开新一轮任务(agent停下后追加)
+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)
+ 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("/")
+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)
+ 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)
+ 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):
+ 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": chat_messages[-last:]}
+
+@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:
+ await ws.send_json({"type": "hello", "subagents": pool.snapshot(), "chat": chat_messages, "log": conductor.log})
+ while True:
+ data = await ws.receive_json()
+ msg = (data.get("msg") or "").strip()
+ if not msg: continue
+ add_chat(msg, role="user")
+ conductor.notify({"type": "user_message", "msg": msg})
+ except WebSocketDisconnect: pass
+ finally: ws_clients.discard(ws)
+
+if __name__ == "__main__":
+ import uvicorn, 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/conductor_im_plugins/_TEMPLATE.py b/frontends/conductor_im_plugins/_TEMPLATE.py
new file mode 100644
index 000000000..a1dc55245
--- /dev/null
+++ b/frontends/conductor_im_plugins/_TEMPLATE.py
@@ -0,0 +1,19 @@
+"""新IM插件模板:复制为 yourim.py(_开头的不加载),填好下面三件套即可被自动发现。
+
+契约(与 wechat.py / email.py 同构):
+ INTERVAL : check() 的轮询间隔(秒)
+ PROMPT : 派给采集subagent的完整指令。它是有记忆/工具的完整GA,
+ 所以只需给三样:用什么工具看新消息(指向SOP)、按什么标准过滤、汇报边界。
+ 注意限量:让它用"天然有界"的工具(如最近N条),禁止开放式全量扫描。
+ check() : 现在有新东西吗?必须廉价(毫秒级,无LLM)。框架另有冷却,宁可误报。
+"""
+
+INTERVAL = 60
+
+PROMPT = """\
+你是XX采集subagent。先读记忆中用户画像,再用<工具/SOP名>查看最近消息,过滤后汇报值得关注项并补全上下文。
+不执行外部动作;无值得关注的就一句话说明。"""
+
+
+def check() -> bool:
+ return False
diff --git a/frontends/conductor_im_plugins/_email_example.py b/frontends/conductor_im_plugins/_email_example.py
new file mode 100644
index 000000000..b9b43b156
--- /dev/null
+++ b/frontends/conductor_im_plugins/_email_example.py
@@ -0,0 +1,15 @@
+"""邮件:存在未读邮件 → 有新东西。"""
+
+INTERVAL = 7200
+
+PROMPT = """\
+你是邮件采集subagent。先读记忆中用户画像,再用 ezgmail 检查未读邮件,过滤后汇报值得关注项并补全上下文(发件人身份/线程/附件摘要)。
+过滤营销和自动通知,不确定的标"低优先级观察"。不回复不执行外部动作;无值得关注的就一句话说明。"""
+
+
+def check() -> bool:
+ try:
+ import ezgmail
+ return bool(ezgmail.unread())
+ except Exception:
+ return False
diff --git a/frontends/conductor_im_plugins/_lark_example.py b/frontends/conductor_im_plugins/_lark_example.py
new file mode 100644
index 000000000..7cb7771b4
--- /dev/null
+++ b/frontends/conductor_im_plugins/_lark_example.py
@@ -0,0 +1,27 @@
+"""飞书:lark-cli 轮询 config.local.json {"lark_chat_ids": [...]} 中的会话,INTERVAL 内有新消息 → 触发。"""
+import glob, json, os, subprocess, time
+
+INTERVAL = 300
+
+PROMPT = """\
+你是飞书采集subagent。先读记忆中用户画像,再用 lark-cli 查看最近消息(详见 lark_cli_sop;会话ID取本插件目录 config.local.json 的 lark_chat_ids,逐个 `lark-cli im +chat-messages-list --as user --chat-id --sort desc --page-size 10`),挑出刚出现的新消息,过滤后汇报值得关注项并补全上下文。
+不执行外部动作;无值得关注的就一句话说明。"""
+
+_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.local.json")
+_NODE = glob.glob(os.path.expandvars(r"%APPDATA%\fnm\node-versions\*\installation")) # lark-cli在fnm node下
+
+
+def check() -> bool:
+ cfg = json.load(open(_CFG, encoding="utf-8")) if os.path.exists(_CFG) else {}
+ env = {**os.environ, "PATH": os.pathsep.join(_NODE + [os.environ.get("PATH", "")])}
+ start = int(time.time()) - INTERVAL - 5
+ for cid in cfg.get("lark_chat_ids", []):
+ r = subprocess.run(
+ f"lark-cli im +chat-messages-list --as user --chat-id {cid} --start {start} --page-size 1",
+ shell=True, capture_output=True, text=True, encoding="utf-8", errors="replace", env=env)
+ try:
+ if json.loads(r.stdout)["data"]["total"]:
+ return True
+ except Exception:
+ pass
+ return False
diff --git a/frontends/continue_cmd.py b/frontends/continue_cmd.py
new file mode 100644
index 000000000..5314d51d9
--- /dev/null
+++ b/frontends/continue_cmd.py
@@ -0,0 +1,1058 @@
+"""`/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)
+ history.append({'role': 'assistant', 'content': blocks})
+ return history
+
+
+_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):
+ """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)
+ 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]', '### [总结提炼经验]')
+
+# project_mode 插件把 `\n\n---\n[PROJECT MODE: ]\n…\n---` 追加在用户消息末尾
+# (见 plugins/project_mode._build_injection)。它会进日志,所以 /continue 重建 UI 时
+# 必须从显示文本里剔除,只留用户原话。不能加进 _INJECT_MARKERS——那会把整块(连用户
+# 原话)一起丢弃;这里只剜掉注入这一段后缀。
+_PM_BLOCK_RE = re.compile(r"\n*-{3,}\n\[PROJECT MODE:.*?\n-{3,}\s*$", re.DOTALL)
+
+
+def strip_project_mode(text: str) -> str:
+ """剔除用户文本尾部的 project-mode 注入块。"""
+ 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):
+ """把 `path` 解析进 backend.history(native;否则降级摘要)。镜像 restore() 的解析,
+ 但不 abort/不快照(日志重指由调用方先做好)。返回 (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:
+ _replace_backend_history(agent, history)
+ 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 continue_inplace(agent, path, agent_id=None):
+ """原地续:把 agent 的日志指回 `path` 本身,之后轮次追加到 X,延续同一会话。
+ 调用方应已确认空闲(session_occupant 为 None);抢锁失败(被占)返回错误。
+ 返回 (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)
+ return _load_history_into(agent, path)
+
+
+def continue_copy(agent, path, agent_id=None):
+ """拷贝续:铸新 logid、把 `path` 内容拷进去,在副本上续;`path` 原件不动。
+ 用于"被占用→用户选拷贝"以及快照源。返回 (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)
+ return _load_history_into(agent, newp)
+
+
+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 000000000..744258982
--- /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 000000000..481196d8c
--- /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 000000000..b051b24a6
--- /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 000000000..24e549dc1
--- /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 000000000..5108f2d35
--- /dev/null
+++ b/frontends/desktop/src-tauri/Cargo.lock
@@ -0,0 +1,5195 @@
+# 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 = "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-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-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 = "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 = "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",
+ "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 = "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 = "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 = "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 = "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 = "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 = "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 = "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 = "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",
+ "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 000000000..297f8e1de
--- /dev/null
+++ b/frontends/desktop/src-tauri/Cargo.toml
@@ -0,0 +1,18 @@
+[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"
+
+[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 000000000..d860e1e6a
--- /dev/null
+++ b/frontends/desktop/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ 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 000000000..509107c5c
--- /dev/null
+++ b/frontends/desktop/src-tauri/capabilities/default.json
@@ -0,0 +1,15 @@
+{
+ "identifier": "default",
+ "description": "Default capabilities for all windows",
+ "windows": ["main", "setup"],
+ "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"
+ ]
+}
diff --git a/frontends/desktop/src-tauri/icons/128x128.png b/frontends/desktop/src-tauri/icons/128x128.png
new file mode 100644
index 000000000..7182b4cbb
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 000000000..8b46700a1
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 000000000..ffd452b6e
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 000000000..92218b9a3
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 000000000..671544e93
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 000000000..e210843bb
Binary files /dev/null and b/frontends/desktop/src-tauri/icons/icon.png differ
diff --git a/frontends/desktop/src-tauri/src/lib.rs b/frontends/desktop/src-tauri/src/lib.rs
new file mode 100644
index 000000000..853c9805e
--- /dev/null
+++ b/frontends/desktop/src-tauri/src/lib.rs
@@ -0,0 +1,344 @@
+use std::process::{Command, Child};
+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()
+}
+
+fn find_bridge_script() -> PathBuf {
+ // exe is at frontends/GenericAgent.exe
+ // bridge is at frontends/desktop_bridge.py
+ std::env::current_exe()
+ .expect("cannot get exe path")
+ .parent().expect("cannot get exe dir")
+ .join("desktop_bridge.py")
+}
+
+/// Find python executable:
+/// 1. .portable/uv-python/ 下找 python.exe (Windows) 或 python3 (Unix)
+/// 2. Fallback to system PATH
+fn find_python() -> 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 project directory by searching upward from exe for agentmain.py
+fn find_project_dir() -> Option {
+ let exe = std::env::current_exe().ok()?;
+ let mut dir = exe.parent();
+ // Walk up to 8 levels from exe location
+ 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 config from settings file, or auto-discover and save
+pub fn get_or_discover_config() -> (String, String) {
+ let path = settings_path();
+
+ // Try reading existing settings
+ if 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() {
+ let json = serde_json::json!({
+ "python_path": python,
+ "project_dir": project
+ });
+ let _ = std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap());
+ }
+
+ (python, project)
+}
+
+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 start_bridge() {
+ let script = find_bridge_script();
+ if !script.exists() {
+ eprintln!("[tauri] bridge script not found: {:?}", script);
+ return;
+ }
+
+ let python = find_python();
+ eprintln!("[tauri] using python: {}", python);
+
+ let show_console = std::env::args().any(|a| a == "--console");
+
+ let mut cmd = Command::new(&python);
+ cmd.arg(&script)
+ .current_dir(script.parent().unwrap());
+
+ #[cfg(windows)]
+ if !show_console {
+ const CREATE_NO_WINDOW: u32 = 0x08000000;
+ cmd.creation_flags(CREATE_NO_WINDOW);
+ }
+
+ match cmd.spawn() {
+ Ok(child) => {
+ eprintln!("[tauri] started bridge PID={}", child.id());
+ *BRIDGE_PROCESS.lock().unwrap() = Some(child);
+ }
+ Err(e) => {
+ eprintln!("[tauri] failed to start bridge: {} (python={})", e, python);
+ return;
+ }
+ }
+
+ if !wait_for_port(14168, Duration::from_secs(15)) {
+ eprintln!("[tauri] WARNING: bridge did not become ready within 15s");
+ }
+}
+
+fn ensure_bridge_running() {
+ if is_bridge_running() {
+ eprintln!("[tauri] bridge already running on 127.0.0.1:14168; reusing it");
+ return;
+ }
+ start_bridge();
+}
+
+#[tauri::command]
+fn start_bridge_with_config(app_handle: tauri::AppHandle, python_path: String, project_dir: String) -> Result<(), String> {
+ // Save to settings
+ let path = settings_path();
+ let obj = serde_json::json!({"python_path": python_path, "project_dir": project_dir});
+ std::fs::write(&path, serde_json::to_string_pretty(&obj).unwrap())
+ .map_err(|e| format!("Failed to write settings: {}", e))?;
+
+ // Start bridge only if it is not already accepting connections.
+ if !is_bridge_running() {
+ 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);
+ #[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);
+ }
+
+ // Wait for port
+ if !wait_for_port(14168, Duration::from_secs(20)) {
+ return Err("Bridge did not become ready within 20s".into());
+ }
+
+ // Navigate main window to bridge URL after the bridge is ready, then show it.
+ 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();
+ }
+
+ Ok(())
+}
+
+#[tauri::command]
+fn get_config() -> (String, String) {
+ get_or_discover_config()
+}
+
+#[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 bridge_ok = is_bridge_running();
+ let mut spawned_bridge = false;
+ if !bridge_ok && !no_autostart {
+ // 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);
+ #[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, get_config])
+ .setup(move |app| {
+ let bridge_wait = if spawned_bridge {
+ Duration::from_secs(20)
+ } else {
+ Duration::from_secs(2)
+ };
+ let bridge_ready = wait_for_port(14168, bridge_wait);
+ if bridge_ready {
+ // Navigate to bridge HTTP only after it is ready; the window starts on loading.html
+ // so WebView never caches an early "connection refused" error page.
+ if let Some(w) = app.get_webview_window("main") {
+ let url = tauri::Url::parse("http://127.0.0.1:14168/").unwrap();
+ 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();
+ }
+ } else {
+ // Show setup window
+ if let Some(w) = app.get_webview_window("setup") {
+ if dev_mode {
+ w.open_devtools();
+ }
+ let _ = w.show();
+ }
+ }
+ Ok(())
+ })
+ .on_window_event(|window, event| {
+ if let tauri::WindowEvent::CloseRequested { .. } = event {
+ let label = window.label();
+ if label == "main" {
+ // Main closed -> exit app
+ 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 000000000..ab57d1cb9
--- /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 000000000..1bb06aeac
--- /dev/null
+++ b/frontends/desktop/src-tauri/tauri.conf.json
@@ -0,0 +1,40 @@
+{
+ "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,
+ "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 000000000..79b33dbb0
--- /dev/null
+++ b/frontends/desktop/static/app.js
@@ -0,0 +1,2118 @@
+window.process = window.process || { platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32' };
+// GenericAgent Desktop — Renderer Logic
+// Handles UI state, sessions, streaming, slash commands.
+
+'use strict';
+
+// ─── State ────────────────────────────────────────────────────────────────
+const state = {
+ sessions: new Map(), // localSessionId -> { id, bridgeSessionId, title, messages: [], cwd, config, diagnostics }
+ activeId: null,
+ bridgeReady: false,
+ defaultConfig: { theme: 'auto', llmNo: 0, gaRoot: '' },
+ modelProfiles: [],
+ restartingBridge: false,
+ bridgeNoticeMessage: null,
+ mykeyReady: true,
+ runtimeBySessionId: new Map(),
+};
+
+// Helper: get config/diagnostics for the active session (or defaults)
+function getActiveConfig() {
+ const sess = state.sessions.get(state.activeId);
+ return sess ? sess.config : state.defaultConfig;
+}
+function getActiveDiagnostics() {
+ const sess = state.sessions.get(state.activeId);
+ return sess ? sess.diagnostics : [];
+}
+
+// ─── DOM refs ─────────────────────────────────────────────────────────────
+const $ = (id) => document.getElementById(id);
+const messagesEl = $('messages');
+const inputEl = $('input');
+const sendBtn = $('send-btn');
+const sessionListEl = $('session-list');
+const sessionTitleEl = $('session-title');
+const statusBadge = $('status-badge');
+const statusText = $('status-text');
+const settingsModal = $('settings-modal');
+const errorBanner = $('error-banner');
+const diagnosticsPanel = $('diagnostics-panel');
+const diagnosticsLogEl = $('diagnostics-log');
+
+
+// ─── Diagnostics ─────────────────────────────────────────────────────────
+const MAX_DIAGNOSTICS = 200;
+
+function diagnosticText(payload) {
+ if (payload == null) return '';
+ if (typeof payload === 'string') return payload;
+ if (payload instanceof Error) return payload.stack || payload.message;
+ try {
+ return JSON.stringify(payload);
+ } catch (_) {
+ return String(payload);
+ }
+}
+
+function addDiagnostic(level, message, payload) {
+ const ts = new Date().toISOString();
+ const detail = diagnosticText(payload);
+ const diags = getActiveDiagnostics();
+ diags.push({ ts, level, message, detail });
+ if (diags.length > MAX_DIAGNOSTICS) diags.shift();
+ renderDiagnostics();
+}
+
+function formatDiagnostics() {
+ const diags = getActiveDiagnostics();
+ if (diags.length === 0) return 'No diagnostics yet.';
+ return diags.map((entry) => {
+ const suffix = entry.detail ? `\n ${entry.detail}` : '';
+ return `[${entry.ts}] ${entry.level.toUpperCase()} ${entry.message}${suffix}`;
+ }).join('\n');
+}
+
+function renderDiagnostics() {
+ if (diagnosticsLogEl) diagnosticsLogEl.textContent = formatDiagnostics();
+}
+
+function openDiagnostics() {
+ renderDiagnostics();
+ diagnosticsPanel.classList.remove('hidden');
+}
+
+function closeDiagnostics() {
+ diagnosticsPanel.classList.add('hidden');
+}
+
+async function copyDiagnostics() {
+ const text = formatDiagnostics();
+ try {
+ await navigator.clipboard.writeText(text);
+ addDiagnostic('info', 'Diagnostics copied to clipboard');
+ } catch (err) {
+ addDiagnostic('error', 'Failed to copy diagnostics', err);
+ showError('Failed to copy diagnostics: ' + (err.message || err), null, null, { skipDiagnostic: true });
+ }
+}
+
+function clearDiagnostics() {
+ const sess = state.sessions.get(state.activeId);
+ if (sess) sess.diagnostics = [];
+ renderDiagnostics();
+}
+
+// ─── Markdown ─────────────────────────────────────────────────────────────
+if (typeof marked !== 'undefined') {
+ marked.setOptions({
+ gfm: true,
+ breaks: true,
+ mangle: false,
+ headerIds: false
+ });
+}
+
+const ALLOWED_URI_RE = /^(https?:|mailto:|tel:|#|\/)/i;
+
+function renderMarkdown(text) {
+ if (typeof marked === 'undefined') {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+ try {
+ return sanitizeMarkdown(marked.parse(text));
+ } catch (e) {
+ return escapeHtml(text);
+ }
+}
+
+function sanitizeMarkdown(html) {
+ const template = document.createElement('template');
+ template.innerHTML = String(html);
+ const blockedTags = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', 'BASE', 'FORM', 'INPUT', 'BUTTON']);
+ const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT);
+ const removals = [];
+ while (walker.nextNode()) {
+ const el = walker.currentNode;
+ if (blockedTags.has(el.tagName)) {
+ removals.push(el);
+ continue;
+ }
+ for (const attr of Array.from(el.attributes)) {
+ const name = attr.name.toLowerCase();
+ const value = attr.value.trim();
+ if (name.startsWith('on') || name === 'srcdoc') {
+ el.removeAttribute(attr.name);
+ continue;
+ }
+ if ((name === 'href' || name === 'src' || name === 'xlink:href') && value && !ALLOWED_URI_RE.test(value)) {
+ el.removeAttribute(attr.name);
+ }
+ }
+ if (el.tagName === 'A') {
+ el.setAttribute('rel', 'noopener noreferrer');
+ el.setAttribute('target', '_blank');
+ }
+ }
+ for (const el of removals) el.remove();
+ return template.innerHTML;
+}
+
+
+function detectStructuredKind(line) {
+ const trimmed = String(line || '').trim();
+ const m = trimmed.match(/^(TOOL_RECALL|TOOL_REQUEST|TOOL_RESPONSE|COWORK|TUNR|TURN|ACTION|OBSERVATION|THOUGHT|TOOL)[\s:_-]*(.*)$/i);
+ if (m) return { kind: m[1].toUpperCase(), rest: (m[2] || '').trim() };
+
+ // GenericAgent's ACP bridge currently streams tool calls/results as plain
+ // assistant text, not as ACP `tool_call` notifications. Recognize the real
+ // XML-ish markers so streamed code_run/file_read/etc. blocks are folded.
+ if (/^]*>/i.test(trimmed) || /^]*\bname=["'][^"']+["'][^>]*>/i.test(trimmed)) {
+ return { kind: 'TOOL_CALL', rest: trimmed };
+ }
+ if (/^]*>/i.test(trimmed) || /^]*>/i.test(trimmed)) {
+ return { kind: 'TOOL_RESULT', rest: trimmed };
+ }
+ return null;
+}
+
+function isStructuredClosingLine(line, kind, textSoFar) {
+ const trimmed = String(line || '').trim();
+ const block = String(textSoFar || '');
+ if (kind === 'TOOL_CALL') {
+ if (/^<\/function_calls>$/i.test(trimmed)) return true;
+ // Single-invoke streams may omit the wrapper.
+ return /^<\/invoke>$/i.test(trimmed) && !/^\s*$/i.test(trimmed)) return true;
+ // Single-result streams may omit the wrapper.
+ return /^<\/result>$/i.test(trimmed) && !/^\s* tag content only
+ const summaryMatch = raw.match(/\s*([\s\S]*?)\s*<\/summary>/i);
+ if (summaryMatch) {
+ const line = summaryMatch[1].trim().split('\n')[0] || kind;
+ return line.length > 96 ? line.slice(0, 96) + '…' : line;
+ }
+ // No summary tag: show kind only (no body text leakage)
+ if (kind === 'LLM_RUNNING') return 'LLM Running';
+ return kind;
+}
+
+const LLM_RUNNING_MARKER_RE = /(\**LLM Running \(Turn \d+\) \.\.\.\**)/g;
+
+function splitLLMRunningSegments(raw) {
+ const placeholders = [];
+ const protect = value => {
+ placeholders.push(value);
+ return `\u0000PH${placeholders.length - 1}\u0000`;
+ };
+ let safe = String(raw || '').replace(/`{4,}[\s\S]*?`{4,}/g, protect);
+ safe = safe.replace(/`{4,}[^`][\s\S]*$/g, protect);
+ const restore = value => String(value || '').replace(/\u0000PH(\d+)\u0000/g, (_, i) => placeholders[Number(i)] || '');
+ const parts = safe.split(LLM_RUNNING_MARKER_RE).map(restore);
+ if (parts.length < 4) return null;
+ const segments = [];
+ if (parts[0] && parts[0].trim()) segments.push({ kind: 'agent_message_chunk', text: parts[0].trimEnd() });
+ const turns = [];
+ for (let i = 1; i < parts.length; i += 2) {
+ turns.push({ marker: parts[i] || '', content: parts[i + 1] || '' });
+ }
+ turns.forEach((turn, idx) => {
+ const text = `${turn.marker}${turn.content}`.trimEnd();
+ if (!text) return;
+ // Match Streamlit: historical/intermediate LLM Running turns are folded;
+ // the latest turn remains plain so final answers are not hidden by default.
+ segments.push({ kind: idx < turns.length - 1 ? 'LLM_RUNNING' : 'agent_message_chunk', text });
+ });
+ return segments.length ? segments : null;
+}
+
+function splitStructuredSegments(text) {
+ const raw = String(text || '');
+ const llmSegments = splitLLMRunningSegments(raw);
+ if (llmSegments) return llmSegments;
+ const lines = raw.split(/\r?\n/);
+ const segments = [];
+ let buf = [];
+ let kind = 'agent_message_chunk';
+ let inFence = false;
+ const flush = () => {
+ if (!buf.length) return;
+ segments.push({ kind, text: buf.join('\n').trimEnd() });
+ buf = [];
+ };
+ for (const line of lines) {
+ const fence = /^\s*```/.test(line);
+ const hit = !inFence ? detectStructuredKind(line) : null;
+ if (hit && hit.kind !== kind) {
+ flush();
+ kind = hit.kind;
+ buf.push(line);
+ } else {
+ buf.push(line);
+ }
+ if (!inFence && kind !== 'agent_message_chunk' && isStructuredClosingLine(line, kind, buf.join('\n'))) {
+ flush();
+ kind = 'agent_message_chunk';
+ }
+ if (fence) inFence = !inFence;
+ }
+ flush();
+ return segments.length ? segments : [{ kind: 'agent_message_chunk', text: raw }];
+}
+
+function hasUnfencedStructuredMarker(text) {
+ let inFence = false;
+ for (const line of String(text || '').split(/\r?\n/)) {
+ const fence = /^\s*```/.test(line);
+ if (!inFence && detectStructuredKind(line)) return true;
+ if (fence) inFence = !inFence;
+ }
+ return false;
+}
+
+function shouldFoldSegment(kind, text) {
+ return kind !== 'agent_message_chunk' || hasUnfencedStructuredMarker(text);
+}
+
+function getNowMs() {
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') return Math.round(performance.now());
+ return Date.now();
+}
+
+function formatDuration(ms) {
+ const value = Number(ms);
+ if (!Number.isFinite(value) || value < 0) return '';
+ if (value < 1000) return `${Math.max(1, Math.round(value))}ms`;
+ if (value < 60000) return `${(value / 1000).toFixed(value < 10000 ? 1 : 0)}s`;
+ const minutes = Math.floor(value / 60000);
+ const seconds = Math.round((value % 60000) / 1000);
+ return `${minutes}m ${seconds}s`;
+}
+
+function formatTaskElapsed(ms, ended) {
+ const totalSeconds = Math.max(0, Math.floor(Number(ms) / 1000));
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+ const parts = [];
+ if (hours) parts.push(`${hours}h`);
+ if (hours || minutes) parts.push(`${minutes}min`);
+ parts.push(`${seconds}s`);
+ const elapsed = parts.join(' ');
+ if (ended) return `Done ✓ ${elapsed}`;
+ const spinner = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
+ const frame = spinner[Math.floor(Date.now() / 1000) % spinner.length];
+ return `${frame} ${elapsed}`;
+}
+
+function getSessionRuntime(sess) {
+ if (!sess) return null;
+ const sessionId = sess.id;
+ let runtime = state.runtimeBySessionId.get(sessionId);
+ if (!runtime) {
+ runtime = {
+ busy: false,
+ currentTurnEl: null,
+ lastMessageType: null,
+ taskStartedAt: 0,
+ taskTimerId: null,
+ assistantDraft: null,
+
+ };
+ state.runtimeBySessionId.set(sessionId, runtime);
+ }
+ return runtime;
+}
+
+function getActiveSessionRuntime() {
+ const sess = state.sessions.get(state.activeId);
+ return sess ? getSessionRuntime(sess) : null;
+}
+
+function findSessionByBridgeId(bridgeSessionId) {
+ if (!bridgeSessionId) return state.sessions.get(state.activeId) || null;
+ for (const sess of state.sessions.values()) {
+ if (sess.bridgeSessionId === bridgeSessionId || sess.id === bridgeSessionId) return sess;
+ }
+ return null;
+}
+
+function isActiveSession(sess) {
+ return !!sess && sess.id === state.activeId;
+}
+
+function withSessionDom(sess, fn) {
+ if (isActiveSession(sess)) return fn();
+ return null;
+}
+
+function updateTaskRuntimeBadges(now = getNowMs()) {
+ const badges = document.querySelectorAll('.task-elapsed[data-started-at]');
+ badges.forEach((badge) => {
+ const startedAt = Number(badge.dataset.startedAt || 0);
+ if (startedAt) badge.textContent = formatTaskElapsed(now - startedAt);
+ });
+}
+
+function clearTaskTimer(sess) {
+ const runtime = sess ? getSessionRuntime(sess) : getActiveSessionRuntime();
+ if (runtime?.taskTimerId) {
+ clearInterval(runtime.taskTimerId);
+ runtime.taskTimerId = null;
+ }
+}
+
+function startTaskTimer(sess, startedAt = getNowMs()) {
+ const runtime = getSessionRuntime(sess);
+ clearTaskTimer(sess);
+ runtime.taskStartedAt = Number(startedAt) || getNowMs();
+ if (isActiveSession(sess)) updateTaskRuntimeBadges(runtime.taskStartedAt);
+ runtime.taskTimerId = setInterval(() => {
+ if (isActiveSession(sess)) updateTaskRuntimeBadges();
+ }, 1000);
+}
+
+function stopTaskTimer(sess) {
+ if (isActiveSession(sess)) updateTaskRuntimeBadges();
+ const runtime = getSessionRuntime(sess);
+ clearTaskTimer(sess);
+ runtime.taskStartedAt = 0;
+}
+
+function taskElapsedBadge(startedAt, endedAt) {
+ const start = Number(startedAt || 0);
+ if (!start) return '';
+ const end = Number(endedAt || 0);
+ const now = end || getNowMs();
+ const ended = !!end;
+ const liveAttr = ended ? 'data-ended="1"' : `data-started-at="${escapeHtml(String(start))}"`;
+ return `${escapeHtml(formatTaskElapsed(now - start, ended))} `;
+}
+
+function ensureAssistantTaskElapsed(wrap, startedAt, endedAt) {
+ if (!wrap) return null;
+ const html = taskElapsedBadge(startedAt, endedAt);
+ let badge = wrap.querySelector(':scope > .task-elapsed');
+ if (!html) {
+ badge?.remove();
+ return null;
+ }
+ if (!badge) {
+ wrap.insertAdjacentHTML('afterbegin', html);
+ badge = wrap.querySelector(':scope > .task-elapsed');
+ } else {
+ const holder = document.createElement('div');
+ holder.innerHTML = html;
+ badge.replaceWith(holder.firstElementChild);
+ badge = wrap.querySelector(':scope > .task-elapsed');
+ }
+ return badge;
+}
+
+function turnLabelForSegment(seg, index) {
+ const summary = summarizeStructuredBlock(seg.kind, seg.text);
+ if (seg.kind === 'LLM_RUNNING') return summary || `Turn ${index + 1}`;
+ if (seg.kind === 'TOOL_CALL') return 'Tool';
+ if (seg.kind === 'TOOL_RESULT') return 'Result';
+ return summary || seg.kind || `Turn ${index + 1}`;
+}
+
+function nextTurnIndexForWrap(wrap) {
+ const current = Number(wrap?.dataset?.turnIndex || 0) || 0;
+ const next = current + 1;
+ if (wrap) wrap.dataset.turnIndex = String(next);
+ return next;
+}
+
+function turnHeaderLabel(index, label) {
+ return `Turn ${index} : ${label || 'response'}`;
+}
+
+function groupIntoTurns(segments, options = {}) {
+ let foldIndex = 0;
+ return (segments || []).map((seg) => {
+ if (!shouldFoldSegment(seg.kind, seg.text)) return { type: 'plain', segment: seg };
+ const index = ++foldIndex;
+ return {
+ type: 'turn',
+ index,
+ label: turnLabelForSegment(seg, index - 1),
+ segment: seg
+ };
+ });
+}
+
+function extractTagBody(text, tag) {
+ const escapedTag = String(tag || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const pattern = '<' + escapedTag + '\\b[^>]*>([\\s\\S]*?)<\\/' + escapedTag + '>';
+ const m = String(text || '').match(new RegExp(pattern, 'i'));
+ return m ? m[1].trim() : '';
+}
+
+function parseToolDetails(kind, text) {
+ const raw = String(text || '');
+ if (kind === 'TOOL_CALL') {
+ const invoke = raw.match(/]*\bname=["']([^"']+)["'][^>]*>/i);
+ const tool = invoke ? invoke[1] : '';
+ const params = extractTagBody(raw, 'parameter') || extractTagBody(raw, 'arguments') || extractTagBody(raw, 'args');
+ const jsonish = params || (raw.match(/]*>[\s\S]*?<\/invoke>/i)?.[0] || '').replace(/<\/?invoke[^>]*>/gi, '').trim();
+ if (tool || jsonish) return { title: tool ? `Tool: ${tool}` : 'Tool call', tool, args: jsonish };
+ }
+ if (kind === 'TOOL_RESULT') {
+ const result = extractTagBody(raw, 'result') || raw.replace(/<\/?function_results[^>]*>/gi, '').trim();
+ if (result) return { title: 'Tool result', tool: 'result', args: result };
+ }
+ return null;
+}
+
+function renderToolDetailInto(container, seg) {
+ const detail = parseToolDetails(seg.kind, seg.text);
+ if (!detail) return;
+ const detailTurn = document.createElement('div');
+ detailTurn.className = 'turn tool-detail-turn';
+ const header = document.createElement('button');
+ header.type = 'button';
+ header.className = 'turn-header tool-detail-header';
+ header.innerHTML = `▼ ${escapeHtml(detail.title)} args `;
+ header.addEventListener('click', () => detailTurn.classList.toggle('collapsed'));
+ const body = document.createElement('div');
+ body.className = 'turn-body md tool-detail-body';
+ const codeText = `Tool: ${detail.tool || detail.title.replace(/^Tool:\s*/, '') || 'tool'}\nargs:\n${detail.args || ''}`;
+ body.innerHTML = `${escapeHtml(codeText)} `;
+ detailTurn.appendChild(header);
+ detailTurn.appendChild(body);
+ container.appendChild(detailTurn);
+}
+
+function renderTurnTreeInto(container, turn) {
+ const seg = turn.segment;
+ const node = document.createElement('div');
+ node.className = 'turn collapsed structured-turn turn-group';
+ node.dataset.kind = seg.kind;
+ node.dataset.buf = seg.text;
+ const header = document.createElement('button');
+ header.type = 'button';
+ header.className = 'turn-header';
+ header.innerHTML = `▼ ${escapeHtml(turnHeaderLabel(turn.index, turn.label))} `;
+ header.addEventListener('click', () => node.classList.toggle('collapsed'));
+ const body = document.createElement('div');
+ body.className = 'turn-body md';
+ const hasToolDetail = Boolean(parseToolDetails(seg.kind, seg.text));
+ renderToolDetailInto(body, seg);
+ if (!hasToolDetail) {
+ const rendered = document.createElement('div');
+ rendered.className = 'turn-rendered-md';
+ rendered.innerHTML = renderMarkdown(seg.text);
+ body.appendChild(rendered);
+ }
+ node.appendChild(header);
+ node.appendChild(body);
+ container.appendChild(node);
+}
+
+/**
+ * Extract ... from text, render it as a faded italic hint,
+ * and return the remaining text. If no summary tag found, returns text unchanged.
+ */
+/**
+ * Strip leading and tags from text.
+ * Returns { summary, think, remaining } where summary/think are the extracted
+ * content strings (or null), and remaining is the text to render as markdown.
+ */
+function stripLeadingMetaTags(text) {
+ let remaining = text;
+ let summary = null;
+ let think = null;
+ // Strip ... at start
+ const sumRe = /^([\s\S]*?)<\/summary>\s*/i;
+ const sumM = remaining.match(sumRe);
+ if (sumM) {
+ summary = sumM[1].trim();
+ remaining = remaining.slice(sumM[0].length);
+ }
+ // Strip ... at start (or after summary)
+ const thinkRe = /^([\s\S]*?)<\/think>\s*/i;
+ const thinkM = remaining.match(thinkRe);
+ if (thinkM) {
+ think = thinkM[1].trim();
+ remaining = remaining.slice(thinkM[0].length);
+ }
+ return { summary, think, remaining };
+}
+
+function extractAndRenderSummary(container, text) {
+ const { summary, think, remaining } = stripLeadingMetaTags(text);
+ if (summary) {
+ const hint = document.createElement('div');
+ hint.className = 'summary-hint';
+ hint.textContent = summary;
+ container.appendChild(hint);
+ }
+ if (think) {
+ const thinkEl = document.createElement('div');
+ thinkEl.className = 'think-hint';
+ thinkEl.textContent = think;
+ container.appendChild(thinkEl);
+ }
+ return remaining;
+}
+
+function renderStructuredMarkdownInto(container, text, options = {}) {
+ const segments = splitStructuredSegments(text);
+ container.innerHTML = '';
+ if (segments.length === 1 && !shouldFoldSegment(segments[0].kind, segments[0].text)) {
+ const remaining = extractAndRenderSummary(container, text);
+ if (remaining) container.insertAdjacentHTML('beforeend', renderMarkdown(remaining));
+ return;
+ }
+ for (const item of groupIntoTurns(segments, options)) {
+ if (item.type === 'plain') {
+ const plain = document.createElement('div');
+ plain.className = 'md';
+ const remaining = extractAndRenderSummary(plain, item.segment.text);
+ if (remaining) plain.insertAdjacentHTML('beforeend', renderMarkdown(remaining));
+ container.appendChild(plain);
+ continue;
+ }
+ renderTurnTreeInto(container, item);
+ }
+}
+
+// ─── Copy button injection for code blocks and pre blocks ─────────────────
+function injectCopyButtons(container) {
+ if (!container) return;
+ const blocks = container.querySelectorAll('pre');
+ blocks.forEach(pre => {
+ if (pre.querySelector('.copy-btn')) return; // already injected
+ pre.style.position = 'relative';
+ const btn = document.createElement('button');
+ btn.className = 'copy-btn';
+ btn.textContent = 'Copy';
+ btn.setAttribute('aria-label', 'Copy code');
+ btn.addEventListener('click', () => {
+ const code = pre.querySelector('code') || pre;
+ navigator.clipboard.writeText(code.textContent).then(() => {
+ btn.textContent = '✓ Copied';
+ btn.classList.add('copied');
+ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
+ }).catch(() => {
+ btn.textContent = '✗ Failed';
+ setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
+ });
+ });
+ pre.appendChild(btn);
+ });
+}
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, c => ({
+ '&':'&','<':'<','>':'>','"':'"',"'":'''
+ }[c]));
+}
+
+// ─── Session management ──────────────────────────────────────────────────
+function isUntitledSessionTitle(title) {
+ return !title || /^new\s+chat$/i.test(String(title).trim());
+}
+
+function createLocalSession(id, title, bridgeSessionId = id) {
+ const sess = {
+ id, bridgeSessionId, title: title || 'New chat', messages: [], cwd: null,
+ untitled: isUntitledSessionTitle(title),
+ config: { ...state.defaultConfig },
+ diagnostics: [],
+ };
+ getSessionRuntime(sess);
+ // Keep freshly-created chats visually quiet: the empty state is enough guidance.
+ state.sessions.set(id, sess);
+ renderSessionList();
+ return sess;
+}
+
+function setActiveSession(id) {
+ // Save scroll position of current session before switching
+ if (state.activeId) {
+ const prevRuntime = state.runtimeBySessionId.get(state.activeId);
+ if (prevRuntime) prevRuntime.scrollPos = messagesEl.scrollTop;
+ }
+ state.activeId = id;
+ const sess = state.sessions.get(id);
+ if (!sess) return;
+ sessionTitleEl.textContent = sess.title;
+ renderMessages();
+ renderSessionList();
+ renderDiagnostics();
+ const runtime = getSessionRuntime(sess);
+ setBusy(runtime.busy, runtime.busy ? 'Agent is responding…' : null, sess);
+ // When switching to a session that is still running, ensure the live draft
+ // is rendered immediately and polling is active (it may have been started
+ // earlier but its render calls were no-ops because the session wasn't active).
+ if (runtime.busy) {
+ const draft = runtime.assistantDraft;
+ if (draft && !draft.finalized) {
+ renderAssistantDraftInPlace(sess, draft);
+ }
+ // Restart polling if it stopped (e.g. page reload or race condition)
+ if (!runtime.polling) {
+ runtime.forcePollOnce = true;
+ pollSessionMessages(sess);
+ } else {
+ // Polling is running but was rendering as no-op while we were away.
+ // Do an immediate one-shot poll to refresh the view right now.
+ (async () => {
+ try {
+ const res = await GaBridge.pollSession(sess.bridgeSessionId || sess.id, runtime.lastPolledMessageId || 0);
+ if (res?.error) return;
+ const result = res.result || res;
+ for (const msg of (result.messages || [])) upsertPolledMessage(sess, msg, { partial: false });
+ if (result.partial) upsertPolledMessage(sess, result.partial, { partial: true });
+ } catch(e) { /* ignore, regular polling will handle it */ }
+ })();
+ }
+ }
+}
+
+function renderSessionList() {
+ // Preserve the + button (must remain in DOM as anchor for insertBefore)
+ const newBtn = document.getElementById('new-session-btn');
+ // Remove only existing tab elements, never the + button
+ sessionListEl.querySelectorAll('.session-tab').forEach((el) => el.remove());
+ if (state.sessions.size === 0) return;
+ for (const sess of state.sessions.values()) {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = 'session-tab' + (sess.id === state.activeId ? ' active' : '');
+ item.setAttribute('role', 'tab');
+ item.setAttribute('aria-selected', sess.id === state.activeId ? 'true' : 'false');
+ item.setAttribute('data-session-id', sess.id);
+ item.title = sess.title;
+ // ─── Drag-and-drop reorder ───
+ item.draggable = true;
+ item.addEventListener('dragstart', (e) => {
+ e.dataTransfer.setData('text/plain', sess.id);
+ e.dataTransfer.effectAllowed = 'move';
+ item.classList.add('dragging');
+ });
+ item.addEventListener('dragend', () => {
+ item.classList.remove('dragging');
+ sessionListEl.querySelectorAll('.session-tab.drag-over').forEach(el => el.classList.remove('drag-over'));
+ });
+ item.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ item.classList.add('drag-over');
+ });
+ item.addEventListener('dragleave', () => {
+ item.classList.remove('drag-over');
+ });
+ item.addEventListener('drop', (e) => {
+ e.preventDefault();
+ item.classList.remove('drag-over');
+ const draggedId = e.dataTransfer.getData('text/plain');
+ if (draggedId && draggedId !== sess.id) {
+ reorderSession(draggedId, sess.id);
+ }
+ });
+ // Per-tab status dot
+ const dot = document.createElement('span');
+ dot.className = 'tab-dot';
+ const runtime = getSessionRuntime(sess);
+ if (runtime && runtime.busy) dot.classList.add('busy');
+ item.appendChild(dot);
+ // Tab label
+ const label = document.createElement('span');
+ label.className = 'tab-label';
+ label.textContent = sess.title;
+ item.appendChild(label);
+ // Close button (Chrome-style ×)
+ const closeBtn = document.createElement('span');
+ closeBtn.className = 'tab-close';
+ closeBtn.setAttribute('role', 'button');
+ closeBtn.setAttribute('aria-label', 'Close tab');
+ closeBtn.textContent = '×';
+ closeBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ closeSession(sess.id);
+ });
+ item.appendChild(closeBtn);
+ item.addEventListener('click', () => setActiveSession(sess.id));
+ sessionListEl.insertBefore(item, newBtn);
+ }
+}
+
+// ─── Tab drag reorder helper ─────────────────────────────────────────────────
+function reorderSession(draggedId, targetId) {
+ const entries = [...state.sessions.entries()];
+ const fromIdx = entries.findIndex(([id]) => id === draggedId);
+ const toIdx = entries.findIndex(([id]) => id === targetId);
+ if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
+ const [moved] = entries.splice(fromIdx, 1);
+ entries.splice(toIdx, 0, moved);
+ state.sessions = new Map(entries);
+ renderSessionList();
+}
+
+function closeSession(id) {
+ if (state.sessions.size <= 1) return; // Don't close the last tab
+ // Notify bridge to delete this session
+ const sess = state.sessions.get(id);
+ if (sess && sess.bridgeSessionId) {
+ const bridgeUrl = window.ga.bridgeUrl || 'http://127.0.0.1:14168';
+ fetch(`${bridgeUrl}/session/${sess.bridgeSessionId}`, { method: 'DELETE' }).catch(() => {});
+ }
+ const keys = [...state.sessions.keys()];
+ const idx = keys.indexOf(id);
+ state.sessions.delete(id);
+ state.runtimeBySessionId.delete(id);
+ if (state.activeId === id) {
+ // Switch to adjacent tab (prefer right, fallback left)
+ const newIdx = Math.min(idx, keys.length - 2);
+ const remaining = [...state.sessions.keys()];
+ setActiveSession(remaining[Math.max(0, Math.min(newIdx, remaining.length - 1))]);
+ } else {
+ renderSessionList();
+ }
+}
+
+async function newSession() {
+ if (!state.bridgeReady) {
+ showError('Bridge is not ready yet. Please wait a moment.');
+ return;
+ }
+ const previousSess = state.sessions.get(state.activeId) || null;
+ // Don't mark previousSess as busy - it's not doing anything
+ // Just show status text without changing any tab dot
+ const statusEl = $('status');
+ if (statusEl) statusEl.textContent = 'Creating session…';
+ let createdSess = null;
+ try {
+ const cwd = await getCwd();
+ const res = await window.ga.rpc('session/new', { cwd, mcp_servers: [] });
+ if (res.error) throw new Error(typeof res.error === 'string' ? res.error : (res.error.message || JSON.stringify(res.error)));
+ const bridgeSessionId = res.sessionId;
+ const localSessionId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ createdSess = createLocalSession(localSessionId, 'New chat', bridgeSessionId);
+ createdSess.cwd = cwd;
+ setActiveSession(localSessionId);
+ } catch (e) {
+ showError('Failed to create session: ' + e.message);
+ } finally {
+ setBusy(false, null, createdSess || previousSess);
+ }
+}
+
+async function getCwd() {
+ // Use GA root as default cwd
+ const status = await window.ga.checkStatus();
+ return status.gaRoot;
+}
+
+// ─── Messages rendering ──────────────────────────────────────────────────
+// DOM cache: sessionId -> { fragment, scrollTop }
+const _domCache = new Map();
+
+function renderMessages() {
+ const sess = state.sessions.get(state.activeId);
+ const runtime = sess ? getSessionRuntime(sess) : null;
+
+ // Save current DOM + scroll to cache for previous session
+ if (state._prevRenderedId && state._prevRenderedId !== state.activeId) {
+ const frag = document.createDocumentFragment();
+ while (messagesEl.firstChild) frag.appendChild(messagesEl.firstChild);
+ _domCache.set(state._prevRenderedId, {
+ fragment: frag,
+ scrollTop: runtime ? (state.runtimeBySessionId.get(state._prevRenderedId)?.scrollPos ?? 0) : 0,
+ });
+ }
+
+ if (runtime) {
+ runtime.currentTurnEl = null;
+ runtime.lastMessageType = null;
+ }
+
+ const hasSavedMessages = !!sess && sess.messages.length > 0;
+ const hasDraft = !!runtime?.assistantDraft && !runtime.assistantDraft.finalized;
+ if (!sess || (!hasSavedMessages && !hasDraft)) {
+ messagesEl.innerHTML = '';
+ messagesEl.classList.add('empty');
+ messagesEl.innerHTML = `
+
+
New task
+
Task me anything. Type /help for commands.
+
`;
+ state._prevRenderedId = state.activeId;
+ return;
+ }
+
+ messagesEl.classList.remove('empty');
+
+ // Try to restore from cache
+ const cached = _domCache.get(state.activeId);
+ if (cached) {
+ messagesEl.innerHTML = '';
+ messagesEl.appendChild(cached.fragment);
+ _domCache.delete(state.activeId);
+ // If there's a live draft, the cached DOM is stale — re-render the draft portion
+ if (hasDraft) {
+ // Remove the stale assistant wrap (last unfinalized msg-assistant element)
+ const last = messagesEl.lastElementChild;
+ if (last?.classList?.contains('msg-assistant') && last.dataset.finalized !== '1') {
+ last.remove();
+ }
+ renderAssistantDraft(sess, runtime.assistantDraft);
+ }
+ messagesEl.scrollTop = cached.scrollTop;
+ } else {
+ messagesEl.innerHTML = '';
+ for (const m of sess.messages) renderMessage(m, false);
+ if (hasDraft) renderAssistantDraft(sess, runtime.assistantDraft);
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+ }
+ state._prevRenderedId = state.activeId;
+}
+
+function prepareMessagesForContent() {
+ if (messagesEl.classList.contains('empty')) messagesEl.innerHTML = '';
+ messagesEl.classList.remove('empty');
+}
+
+function renderMessage(msg, append = true) {
+ prepareMessagesForContent();
+
+ if (msg.role === 'user') {
+ const wrap = document.createElement('div');
+ wrap.className = 'msg msg-user';
+ let imagesHtml = '';
+ const ids = msg.image_ids || [];
+ if (ids.length > 0) {
+ imagesHtml = '' + ids.map(id => {
+ const dataUrl = sessionStorage.getItem('img:' + id);
+ if (dataUrl) {
+ return `
`;
+ }
+ return `
🖼 `;
+ }).join('') + '
';
+ }
+ wrap.innerHTML = `${imagesHtml}${escapeHtml(msg.content)}
`;
+ messagesEl.appendChild(wrap);
+ const sess = state.sessions.get(state.activeId);
+ const runtime = sess ? getSessionRuntime(sess) : null;
+ if (runtime) {
+ runtime.currentTurnEl = null; // reset turn grouping on user message
+ runtime.lastMessageType = 'user';
+ }
+ } else if (msg.role === 'system') {
+ const wrap = document.createElement('div');
+ wrap.className = 'msg msg-system';
+ wrap.textContent = msg.content;
+ messagesEl.appendChild(wrap);
+ } else if (msg.role === 'error') {
+ const wrap = document.createElement('div');
+ wrap.className = 'msg msg-error';
+ wrap.textContent = msg.content;
+ messagesEl.appendChild(wrap);
+ const sess = state.sessions.get(state.activeId);
+ const runtime = sess ? getSessionRuntime(sess) : null;
+ if (runtime) runtime.currentTurnEl = null;
+ } else if (msg.role === 'assistant') {
+ // Final full message (when reloading from state)
+ const wrap = document.createElement('div');
+ wrap.className = 'msg msg-assistant';
+ if (msg.segments) {
+ ensureAssistantTaskElapsed(wrap, msg.taskStartedAt, msg.taskEndedAt);
+ for (const seg of msg.segments) {
+ wrap.appendChild(buildTurn(seg.kind, seg.text, seg.collapsed, nextTurnIndexForWrap(wrap)));
+ }
+ } else {
+ const body = document.createElement('div');
+ body.className = 'assistant-response md';
+ ensureAssistantTaskElapsed(wrap, msg.taskStartedAt, msg.taskEndedAt);
+ const cleanContent = (msg.content || '').replace(/\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$/, '');
+ renderStructuredMarkdownInto(body, cleanContent);
+ injectCopyButtons(body);
+ wrap.appendChild(body);
+ }
+ injectCopyButtons(wrap);
+ messagesEl.appendChild(wrap);
+ }
+ if (append) scrollToBottom();
+}
+
+function buildTurn(kind, text, collapsed, index) {
+ const turn = document.createElement('div');
+ turn.className = 'turn' + (collapsed ? ' collapsed' : '');
+ turn.dataset.kind = kind;
+ const turnIndex = Number(index || 0);
+ const label = turnIndex ? turnHeaderLabel(turnIndex, kind) : kind;
+ const summary = summarizeStructuredBlock(kind, text);
+ const header = document.createElement('div');
+ header.className = 'turn-header';
+ header.innerHTML = `▼ ${escapeHtml(label)} ${escapeHtml(summary)} `;
+ header.addEventListener('click', () => turn.classList.toggle('collapsed'));
+ const body = document.createElement('div');
+ body.className = 'turn-body md';
+ const hasToolDetail = Boolean(parseToolDetails(kind, text));
+ renderToolDetailInto(body, { kind, text });
+ if (!hasToolDetail) {
+ const rendered = document.createElement('div');
+ rendered.className = 'turn-rendered-md';
+ rendered.innerHTML = renderMarkdown(text);
+ body.appendChild(rendered);
+ }
+ turn.appendChild(header);
+ turn.appendChild(body);
+ injectCopyButtons(body);
+ return turn;
+}
+
+function isNearBottom(threshold = 150) {
+ return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < threshold;
+}
+
+function scrollToBottom(smooth = true) {
+ messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
+}
+
+// ─── Streaming chunks (from ACP bridge notifications) ────────────────────
+// ACP sends method='session/update' with params.update.sessionUpdate=
+// agent_message_chunk | agent_thought_chunk | tool_call | tool_call_update | plan | available_commands_update
+function handleNotification(msg) {
+ // Handle WS session-state notifications from the bridge backend.
+ // These have {type: "session-state", sessionId, state, status, seq, ...}
+ // and are used to kick-start polling for sessions that became active
+ // (e.g. after page reload, or when a background session starts running).
+ if (msg.type === 'session-state') {
+ const sess = findSessionByBridgeId(msg.sessionId);
+ if (!sess) return;
+ const runtime = getSessionRuntime(sess);
+ if ((msg.state === 'running' || msg.status === 'running') && !runtime.polling) {
+ runtime.busy = true;
+ runtime.forcePollOnce = true;
+ setBusy(true, 'Thinking…', sess);
+ pollSessionMessages(sess);
+ } else if (msg.state === 'idle' || msg.state === 'error' || msg.status === 'idle') {
+ // Session finished in background — do a final poll to pick up remaining messages
+ if (!runtime.polling && runtime.busy) {
+ runtime.forcePollOnce = true;
+ pollSessionMessages(sess);
+ }
+ }
+ // Update tab dot regardless
+ renderSessionList();
+ return;
+ }
+ if (msg.method !== 'session/update') return;
+ const update = msg.params?.update;
+ if (!update) return;
+ const kind = update.sessionUpdate;
+ const bridgeSessionId = msg.params?.sessionId || update.sessionId || update.session?.id;
+ const sess = findSessionByBridgeId(bridgeSessionId);
+ if (!sess) return;
+
+
+ if (kind === 'agent_message_chunk') {
+ const text = extractText(update.content);
+ appendAssistantChunk(sess, text);
+ } else if (kind === 'task_started') {
+ hideError();
+ startTaskTimer(sess);
+ setBusy(true, 'Thinking…', sess);
+ } else if (kind === 'task_completed' || kind === 'cancelled') {
+ finalizeAssistantReply(sess);
+ setBusy(false, null, sess);
+ hideError();
+ } else if (kind === 'error') {
+ finalizeAssistantReply(sess);
+ setBusy(false, null, sess);
+ const errText = update.message || update.error || 'Bridge error';
+ sess.messages.push({ role: 'error', content: errText });
+ if (isActiveSession(sess)) renderMessage({ role: 'error', content: errText });
+ showError(errText);
+ } else if (kind === 'agent_thought_chunk') {
+ const text = extractText(update.content);
+ appendStreamChunk(sess, kind, text);
+ } else if (kind === 'tool_call') {
+ const toolName = update.title || update.name || update.kind || update.toolCallId || 'tool';
+ const args = update.arguments || update.args || update.input || update.content || '';
+ const argText = typeof args === 'string' ? args : JSON.stringify(args, null, 2);
+ const text = `
+
+${escapeHtml(argText)}
+
+ `;
+ appendTurn(sess, 'TOOL_CALL', text, true);
+ } else if (kind === 'tool_call_update') {
+ // Status updates, keep simple
+ if (update.status && update.status !== 'in_progress') {
+ appendTurn(sess, 'tool', `[${update.status}] ${update.toolCallId || ''}`, true);
+ }
+ } else if (kind === 'plan') {
+ const lines = (update.entries || []).map(e =>
+ `- [${e.status || 'pending'}] ${e.content || ''}`
+ ).join('\n');
+ appendTurn(sess, 'plan', lines, false);
+ }
+}
+
+function extractText(content) {
+ if (!content) return '';
+ if (typeof content === 'string') return content;
+ if (content.type === 'text') return content.text || '';
+ if (Array.isArray(content)) return content.map(extractText).join('');
+ return '';
+}
+
+function getLiveAssistantWrap(sess) {
+ if (!isActiveSession(sess)) return null;
+ const last = messagesEl.lastElementChild;
+ if (last?.classList?.contains('msg-assistant') && last.dataset.finalized !== '1') return last;
+ const wrap = document.createElement('div');
+ wrap.className = 'msg msg-assistant';
+ const runtime = getSessionRuntime(sess);
+ if (runtime.taskStartedAt) {
+ wrap.dataset.taskStartedAt = String(runtime.taskStartedAt);
+ ensureAssistantTaskElapsed(wrap, runtime.taskStartedAt);
+ }
+ messagesEl.appendChild(wrap);
+ return wrap;
+}
+
+function getAssistantDraft(sess) {
+ const runtime = getSessionRuntime(sess);
+ if (!runtime.assistantDraft || runtime.assistantDraft.finalized) {
+ runtime.assistantDraft = {
+ text: '',
+ segments: [],
+ currentSegmentIndex: -1,
+ taskStartedAt: runtime.taskStartedAt || 0,
+ taskEndedAt: 0,
+ finalized: false,
+ bridgeMessageId: 0
+ };
+ }
+ if (!runtime.assistantDraft.taskStartedAt && runtime.taskStartedAt) runtime.assistantDraft.taskStartedAt = runtime.taskStartedAt;
+ return runtime.assistantDraft;
+}
+
+function renderAssistantDraft(sess, draft) {
+ if (!isActiveSession(sess) || !draft || draft.finalized) return null;
+ prepareMessagesForContent();
+ const wrap = document.createElement('div');
+ wrap.className = 'msg msg-assistant';
+ if (draft.taskStartedAt) {
+ wrap.dataset.taskStartedAt = String(draft.taskStartedAt);
+ ensureAssistantTaskElapsed(wrap, draft.taskStartedAt, draft.taskEndedAt);
+ }
+ if (draft.text) {
+ wrap.dataset.buf = draft.text;
+ const body = document.createElement('div');
+ body.className = 'assistant-response md';
+ renderStructuredMarkdownInto(body, draft.text);
+ injectCopyButtons(body);
+ if (!draft.finalized) body.insertAdjacentHTML('beforeend', ' ');
+ wrap.appendChild(body);
+ }
+ for (const seg of draft.segments || []) {
+ wrap.appendChild(buildTurn(seg.kind, seg.text, seg.collapsed, nextTurnIndexForWrap(wrap)));
+ }
+ injectCopyButtons(wrap);
+ messagesEl.appendChild(wrap);
+ return wrap;
+}
+
+function renderAssistantDraftInPlace(sess, draft) {
+ if (!isActiveSession(sess) || !draft || draft.finalized) return null;
+ prepareMessagesForContent();
+ const runtime = getSessionRuntime(sess);
+ const wrap = getLiveAssistantWrap(sess);
+ if (draft.taskStartedAt || runtime.taskStartedAt) {
+ const startedAt = draft.taskStartedAt || runtime.taskStartedAt;
+ wrap.dataset.taskStartedAt = String(startedAt);
+ ensureAssistantTaskElapsed(wrap, startedAt, draft.taskEndedAt);
+ }
+ wrap.dataset.buf = draft.text || '';
+ let body = wrap.querySelector('.assistant-response');
+ if (!body) {
+ body = document.createElement('div');
+ body.className = 'assistant-response md';
+ // Keep plain assistant text before folded/tool turns.
+ const firstTurn = wrap.querySelector('.turn');
+ if (firstTurn) wrap.insertBefore(body, firstTurn);
+ else wrap.appendChild(body);
+ }
+ renderStructuredMarkdownInto(body, draft.text || '');
+ injectCopyButtons(body);
+ body.insertAdjacentHTML('beforeend', ' ');
+ if (isNearBottom()) scrollToBottom(false);
+ return wrap;
+}
+
+function appendAssistantChunk(sess, text) {
+ if (!text) return;
+ const runtime = getSessionRuntime(sess);
+ const draft = getAssistantDraft(sess);
+ draft.text += text;
+ draft.currentSegmentIndex = -1;
+ runtime.currentTurnEl = null;
+ if (!isActiveSession(sess)) return;
+ prepareMessagesForContent();
+ let wrap = getLiveAssistantWrap(sess);
+ let body = wrap.querySelector('.assistant-response');
+ if (!body) {
+ body = document.createElement('div');
+ body.className = 'assistant-response md';
+ wrap.appendChild(body);
+ }
+ wrap.dataset.buf = draft.text;
+ ensureAssistantTaskElapsed(wrap, draft.taskStartedAt || runtime.taskStartedAt);
+ renderStructuredMarkdownInto(body, draft.text);
+ body.insertAdjacentHTML('beforeend', ' ');
+ if (isNearBottom()) scrollToBottom(false);
+}
+
+function appendStreamChunk(sess, kind, text) {
+ if (!text) return;
+ // Group consecutive chunks of same kind into one turn (fold_turns style)
+ const runtime = getSessionRuntime(sess);
+ const draft = getAssistantDraft(sess);
+ let seg = draft.segments[draft.currentSegmentIndex];
+ if (!seg || seg.kind !== kind) {
+ seg = { kind, text: '', collapsed: false };
+ draft.segments.push(seg);
+ draft.currentSegmentIndex = draft.segments.length - 1;
+ runtime.currentTurnEl = null;
+ }
+ seg.text += text;
+ if (!isActiveSession(sess)) return;
+ let turn = runtime.currentTurnEl;
+ const currentKind = turn?.dataset.kind;
+ if (!turn || currentKind !== kind) {
+ turn = createStreamingTurn(sess, kind);
+ runtime.currentTurnEl = turn;
+ }
+ turn.dataset.buf = seg.text;
+ const body = turn.querySelector('.turn-body');
+ const { summary, remaining: cleanText } = stripLeadingMetaTags(seg.text);
+ // Update the header turn-summary span (visible when collapsed)
+ const summarySpan = turn.querySelector('.turn-summary');
+ if (summarySpan && summary) {
+ summarySpan.textContent = summary;
+ }
+ // Only render the clean body text (no summary-hint in body)
+ body.innerHTML = renderMarkdown(cleanText) + ' ';
+ if (isNearBottom()) scrollToBottom(false);
+}
+
+function appendTurn(sess, kind, text, collapsed) {
+ const draft = getAssistantDraft(sess);
+ draft.segments.push({ kind, text, collapsed: !!collapsed });
+ draft.currentSegmentIndex = -1;
+ if (!isActiveSession(sess)) return;
+ prepareMessagesForContent();
+ const wrap = getLiveAssistantWrap(sess);
+ wrap.appendChild(buildTurn(kind, text, collapsed, nextTurnIndexForWrap(wrap)));
+ if (isNearBottom()) scrollToBottom(false);
+}
+
+function createStreamingTurn(sess, kind) {
+ prepareMessagesForContent();
+ const wrap = getLiveAssistantWrap(sess);
+ const turn = document.createElement('div');
+ turn.className = 'turn';
+ turn.dataset.kind = kind;
+ const displayKind = kind === 'agent_thought_chunk' ? 'thinking' : 'response';
+ const turnIndex = nextTurnIndexForWrap(wrap);
+ turn.innerHTML = `
+
+
`;
+ turn.querySelector('.turn-header').addEventListener('click', () => turn.classList.toggle('collapsed'));
+ // thinking turns collapsed by default once complete
+ if (kind === 'agent_thought_chunk') turn.dataset.autoCollapse = '1';
+ wrap.appendChild(turn);
+ return turn;
+}
+
+function getCurrentAssistantWrap(sess) {
+ if (!isActiveSession(sess)) return null;
+ const last = messagesEl.lastElementChild;
+ if (last?.classList?.contains('msg-assistant') && last.dataset.finalized !== '1') return last;
+ return null;
+}
+
+function finalizeStreamingTurn(sess) {
+ const runtime = getSessionRuntime(sess);
+ const wrap = getCurrentAssistantWrap(sess);
+ const liveAssistant = wrap?.querySelector('.assistant-response');
+ if (liveAssistant) {
+ renderStructuredMarkdownInto(liveAssistant, wrap.dataset.buf || '');
+ injectCopyButtons(liveAssistant);
+ }
+ if (runtime.assistantDraft?.segments?.length) {
+ for (const seg of runtime.assistantDraft.segments) {
+ if (seg.kind === 'agent_thought_chunk') seg.collapsed = true;
+ }
+ }
+ if (!runtime.currentTurnEl) return;
+ const t = runtime.currentTurnEl;
+ const body = t.querySelector('.turn-body');
+ // Remove cursor, strip summary/think tags; set turn-summary in header
+ if (body) {
+ const { summary: extractedSummary, remaining: cleanBuf } = stripLeadingMetaTags(t.dataset.buf || '');
+ body.innerHTML = renderMarkdown(cleanBuf);
+ // Set the turn-summary span in the header for collapsed display
+ const summaryEl = t.querySelector('.turn-summary');
+ if (summaryEl) {
+ const kind = t.dataset.kind || 'response';
+ summaryEl.textContent = extractedSummary || summarizeStructuredBlock(kind, cleanBuf);
+ }
+ }
+ if (t.dataset.autoCollapse === '1') t.classList.add('collapsed');
+ const idx = runtime.assistantDraft?.currentSegmentIndex;
+ if (Number.isInteger(idx) && runtime.assistantDraft?.segments?.[idx]) {
+ runtime.assistantDraft.segments[idx].collapsed = t.classList.contains('collapsed');
+ }
+ runtime.currentTurnEl = null;
+}
+
+function finalizeAssistantReply(sess) {
+ const endedAt = getNowMs();
+ finalizeStreamingTurn(sess);
+ // Remove any residual blinking cursors (e.g. after RPC timeout)
+ messagesEl.querySelectorAll('.cursor').forEach(el => el.remove());
+ const runtime = getSessionRuntime(sess);
+ const draft = runtime.assistantDraft;
+ const wrap = getCurrentAssistantWrap(sess);
+ if (draft && sess && !draft.finalized) {
+ draft.finalized = true;
+ draft.taskEndedAt = endedAt;
+ // Strip trailing [Info] Final response to user. marker (wrapped in 5 backticks)
+ if (draft.text) {
+ draft.text = draft.text.replace(/\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$/, '');
+ }
+ if (draft.segments?.length) {
+ const last = draft.segments[draft.segments.length - 1];
+ if (last && last.text) {
+ last.text = last.text.replace(/\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$/, '');
+ }
+ }
+ const msg = { role: 'assistant', finalized: true, taskEndedAt: endedAt };
+ if (draft.bridgeMessageId) msg.id = Number(draft.bridgeMessageId);
+ if (draft.taskStartedAt) msg.taskStartedAt = Number(draft.taskStartedAt);
+ if (draft.text) msg.content = draft.text;
+ if (draft.segments?.length) msg.segments = draft.segments.map((seg) => ({
+ kind: seg.kind,
+ text: seg.text || '',
+ collapsed: !!seg.collapsed
+ }));
+ if (msg.content || msg.segments?.length) sess.messages.push(msg);
+ runtime.assistantDraft = null;
+ }
+ if (wrap) {
+ wrap.dataset.finalized = '1';
+ wrap.dataset.taskEndedAt = String(endedAt);
+ ensureAssistantTaskElapsed(wrap, wrap.dataset.taskStartedAt || runtime.taskStartedAt || draft?.taskStartedAt, endedAt);
+ renderMessages();
+ } else if (!isActiveSession(sess)) {
+ // Session finished in background — its DOM cache is stale, discard it
+ // so that switching to it will do a full re-render from sess.messages
+ _domCache.delete(sess.id);
+ }
+ stopTaskTimer(sess);
+}
+
+// ─── Sending prompts ─────────────────────────────────────────────────────
+function normalizeBridgeMessage(msg) {
+ return {
+ id: Number(msg.id || 0),
+ role: msg.role || 'system',
+ content: msg.content || '',
+ image_ids: msg.image_ids || []
+ };
+}
+
+function upsertPolledMessage(sess, raw, { partial = false } = {}) {
+ if (!sess || !raw) return;
+ const msg = normalizeBridgeMessage(raw);
+ if (!msg.id) return;
+ const runtime = getSessionRuntime(sess);
+ if (!runtime.seenBridgeMessageIds) runtime.seenBridgeMessageIds = new Set();
+
+ if (partial && msg.role === 'assistant') {
+ const draft = getAssistantDraft(sess);
+ const changed = draft.bridgeMessageId !== msg.id || draft.text !== (msg.content || '');
+ draft.bridgeMessageId = msg.id;
+ draft.text = msg.content || '';
+ draft.currentSegmentIndex = -1;
+ draft.finalized = false;
+ // Polling partial updates used to call renderMessages(), which rebuilt the
+ // whole message list every 500ms. That destroys user fold/collapse DOM
+ // state and can make the live answer appear to jump/duplicate. Update only
+ // the live assistant draft in-place; final messages are still reconciled by
+ // id in the non-partial branch below.
+ if (changed && isActiveSession(sess)) renderAssistantDraftInPlace(sess, draft);
+ return;
+ }
+
+ if (runtime.seenBridgeMessageIds.has(msg.id)) return;
+ runtime.seenBridgeMessageIds.add(msg.id);
+ runtime.lastPolledMessageId = Math.max(Number(runtime.lastPolledMessageId || 0), msg.id);
+
+ const draft = runtime.assistantDraft;
+ if (msg.role === 'assistant' && draft && !draft.finalized && Number(draft.bridgeMessageId || 0) === msg.id) {
+ draft.text = msg.content || draft.text || '';
+ finalizeAssistantReply(sess);
+ return;
+ }
+ sess.messages.push(msg);
+ if (isActiveSession(sess)) renderMessage(msg);
+}
+
+async function pollSessionMessages(sess) {
+ if (!sess) return;
+ const runtime = getSessionRuntime(sess);
+ if (runtime.polling) return;
+ runtime.polling = true;
+ try {
+ while (runtime.busy || runtime.forcePollOnce) {
+ runtime.forcePollOnce = false;
+ const res = await window.ga.pollSession(sess.bridgeSessionId || sess.id, runtime.lastPolledMessageId || 0);
+ if (res?.error) throw new Error(res.error.message || res.error);
+ const result = res.result || res;
+ for (const msg of (result.messages || [])) upsertPolledMessage(sess, msg, { partial: false });
+ if (result.partial) upsertPolledMessage(sess, result.partial, { partial: true });
+ const busy = result.status === 'running' || !!result.partial;
+ setBusy(busy, busy ? 'Thinking…' : null, sess);
+ if (!busy) {
+ finalizeAssistantReply(sess);
+ break;
+ }
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ } catch (e) {
+ addDiagnostic('error', 'Polling failed', e);
+ showError('Polling failed: ' + (e.message || e));
+ setBusy(false, null, sess);
+ } finally {
+ runtime.polling = false;
+ }
+}
+
+async function sendPrompt(text, images = []) {
+ if (!state.bridgeReady) {
+ showError('Bridge is not ready.');
+ return;
+ }
+ if (!state.activeId) {
+ await newSession();
+ if (!state.activeId) return;
+ }
+ const sess = state.sessions.get(state.activeId);
+ const runtime = getSessionRuntime(sess);
+ if (runtime.busy) return;
+
+ // Store images in sessionStorage and collect ids
+ const imageIds = images.map(img => {
+ try { sessionStorage.setItem('img:' + img.id, img.dataUrl); } catch(e) { /* quota */ }
+ return img.id;
+ });
+
+ const localUserMsg = { role: 'user', content: text, image_ids: imageIds };
+ sess.messages.push(localUserMsg);
+ renderMessage(localUserMsg);
+ startTaskTimer(sess);
+ if (sess.untitled || isUntitledSessionTitle(sess.title)) {
+ sess.title = text.trim().slice(0, 40) + (text.trim().length > 40 ? '…' : '');
+ sess.untitled = false;
+ sessionTitleEl.textContent = sess.title;
+ renderSessionList();
+ }
+
+ setBusy(true, 'Thinking…', sess);
+ try {
+ const res = await window.ga.rpc('session/prompt', {
+ sessionId: await ensureBridgeSession(sess),
+ prompt: text,
+ images: images.map(img => ({id: img.id, dataUrl: img.dataUrl})),
+ llmNo: sess.config.llmNo
+ });
+ if (res?.error) throw new Error(res.error.message || res.error);
+ const acceptedUserId = Number(res.userMessageId || res.result?.userMessageId || 0);
+ if (acceptedUserId) {
+ if (!runtime.seenBridgeMessageIds) runtime.seenBridgeMessageIds = new Set();
+ runtime.seenBridgeMessageIds.add(acceptedUserId);
+ runtime.lastPolledMessageId = Math.max(Number(runtime.lastPolledMessageId || 0), acceptedUserId);
+ }
+ runtime.forcePollOnce = true;
+ pollSessionMessages(sess);
+ } catch (e) {
+ sess.messages.push({ role: 'error', content: e.message || String(e) });
+ if (isActiveSession(sess)) renderMessage({ role: 'error', content: e.message || String(e) });
+ setBusy(false, null, sess);
+ }
+}
+
+async function cancelPrompt() {
+ const sess = state.sessions.get(state.activeId);
+ const runtime = sess ? getSessionRuntime(sess) : null;
+ if (!runtime?.busy) return false;
+ try {
+ const res = await window.ga.rpc('session/cancel', { sessionId: sess?.bridgeSessionId || state.activeId });
+ if (res.error) throw new Error(res.error.message || res.error);
+ setBusy(false, null, sess); // clear busy immediately; don't wait for server-side cancelled event
+ return true;
+ } catch (e) {
+ showSystem('Stop failed: ' + (e.message || e));
+ return false;
+ }
+}
+
+// ─── Slash commands ──────────────────────────────────────────────────────
+async function handleSlash(cmd) {
+ const [name, ...rest] = cmd.trim().slice(1).split(/\s+/);
+ const arg = rest.join(' ');
+ const sess = state.sessions.get(state.activeId);
+
+ switch (name) {
+ case 'help':
+ showSystem([
+ 'Available commands:',
+ ' /new New session',
+ ' /clear Clear current session display',
+ ' /stop Cancel the current request',
+ ' /theme Switch theme (light|dark|auto)',
+ ].join('\n'));
+ break;
+ case 'new':
+ await newSession();
+ break;
+ case 'clear':
+ if (sess) { sess.messages = []; renderMessages(); }
+ break;
+ case 'stop':
+ if (await cancelPrompt()) showSystem('Stop requested.');
+ break;
+ case 'restart':
+ await restartBridge();
+ break;
+ case 'settings':
+ openSettings();
+ break;
+ case 'theme':
+ if (['light', 'dark', 'auto'].includes(arg)) {
+ const cfg = getActiveConfig();
+ cfg.theme = arg;
+ applyTheme();
+ await window.ga.saveConfig(cfg);
+ showSystem(`Theme → ${arg}`);
+ } else {
+ showSystem('Usage: /theme light|dark|auto');
+ }
+ break;
+ case 'cwd':
+ if (!arg) {
+ const status = await window.ga.checkStatus();
+ showSystem(`cwd: ${sess?.cwd || status.gaRoot}`);
+ } else {
+ showSystem(`Creating new session in ${arg}…`);
+ // Need a new session for different cwd
+ const res = await window.ga.rpc('session/new', { cwd: arg, mcp_servers: [] });
+ if (res.error) showSystem('Failed: ' + (res.error.message || res.error));
+ else {
+ const bridgeSessionId = res.sessionId;
+ const localSessionId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const ns = createLocalSession(localSessionId, arg.split('/').pop() || arg, bridgeSessionId);
+ ns.cwd = arg;
+ setActiveSession(localSessionId);
+ }
+ }
+ break;
+ default:
+ showSystem(`Unknown command: /${name}. Try /help.`);
+ }
+}
+
+function showSystem(text) {
+ const msg = { role: 'system', content: text };
+ const sess = state.sessions.get(state.activeId);
+ if (sess) sess.messages.push(msg);
+ renderMessage(msg);
+ return msg;
+}
+
+function updateBridgeNotice(text) {
+ const notice = state.bridgeNoticeMessage;
+ state.bridgeNoticeMessage = null;
+ if (!notice) return;
+ notice.content = text;
+ const sess = state.sessions.get(state.activeId);
+ if (sess && sess.messages.includes(notice)) renderMessages();
+}
+
+// ─── Status / UI helpers ─────────────────────────────────────────────────
+function setStatus(kind, text) {
+ statusBadge.className = 'badge ' + kind;
+ statusText.textContent = text;
+ // Update per-tab dot for active session
+ updateTabDot(state.activeId, kind);
+}
+
+function updateTabDot(sessionId, kind) {
+ if (!sessionId) return;
+ const tab = sessionListEl.querySelector(`[data-session-id="${sessionId}"]`);
+ if (!tab) return;
+ const dot = tab.querySelector('.tab-dot');
+ if (!dot) return;
+ dot.className = 'tab-dot';
+ if (kind === 'busy') dot.classList.add('busy');
+ else if (kind === 'warn') dot.classList.add('warn');
+ else if (kind === 'err') dot.classList.add('err');
+ // 'ok' = default green (no extra class needed)
+}
+
+const SEND_ICON = ` `;
+const STOP_ICON = ` `;
+
+function setBusy(busy, label, sess = state.sessions.get(state.activeId)) {
+ const runtime = sess ? getSessionRuntime(sess) : null;
+ if (runtime) runtime.busy = busy;
+ // Always update per-tab dot for this session
+ if (sess) {
+ const dotKind = busy ? 'busy' : (state.bridgeReady ? 'ok' : 'warn');
+ updateTabDot(sess.id, dotKind);
+ }
+ if (!isActiveSession(sess)) return;
+ if (busy) setStatus('busy', label || 'Working…');
+ else setStatus(state.bridgeReady ? 'ok' : 'warn', state.bridgeReady ? 'Ready' : 'Starting…');
+ renderSendButtonState();
+}
+
+function renderSendButtonState() {
+ const hasText = inputEl.value.trim().length > 0;
+ const busy = !!getActiveSessionRuntime()?.busy;
+ sendBtn.classList.toggle('stop', busy);
+ sendBtn.title = busy ? 'Stop (Esc)' : 'Send (Enter)';
+ sendBtn.innerHTML = busy ? STOP_ICON : SEND_ICON;
+ sendBtn.disabled = !hasText && !busy;
+}
+
+function updateSendButton() {
+ renderSendButtonState();
+}
+
+function showError(text, actionLabel, actionFn, options = {}) {
+ if (!options.skipDiagnostic) addDiagnostic('error', text);
+ $('error-text').textContent = text;
+ const actionBtn = $('error-action');
+ if (actionLabel && actionFn) {
+ actionBtn.textContent = actionLabel;
+ actionBtn.classList.remove('hidden');
+ actionBtn.onclick = async () => {
+ try {
+ await actionFn();
+ } catch (err) {
+ showError('Action failed: ' + (err.message || err));
+ }
+ };
+ } else {
+ actionBtn.classList.add('hidden');
+ }
+ errorBanner.classList.remove('hidden');
+ clearTimeout(showError._t);
+ if (!actionLabel) {
+ showError._t = setTimeout(() => errorBanner.classList.add('hidden'), 6000);
+ }
+}
+function hideError() { errorBanner.classList.add('hidden'); }
+
+// ─── Theme ───────────────────────────────────────────────────────────────
+function applyTheme() {
+ const cfg = getActiveConfig();
+ document.documentElement.setAttribute('data-theme', cfg.theme || 'auto');
+}
+
+// ─── Settings modal ──────────────────────────────────────────────────────
+function renderModelOptions() {
+ const select = $('cfg-llm');
+ const selected = String(getActiveConfig().llmNo || 0);
+ const profiles = Array.isArray(state.modelProfiles) ? state.modelProfiles : [];
+ const options = profiles.length ? profiles : [{ llmNo: 0, name: 'Default / Auto' }];
+ select.textContent = '';
+ for (const profile of options) {
+ const opt = document.createElement('option');
+ opt.value = String(profile.llmNo);
+ // Display as "name/model" when both fields available
+ const displayName = profile.name && profile.model
+ ? `${profile.name}/${profile.model}`
+ : profile.name || profile.model || `Model ${profile.llmNo}`;
+ opt.textContent = displayName;
+ select.appendChild(opt);
+ }
+ if (![...select.options].some((opt) => opt.value === selected)) {
+ const opt = document.createElement('option');
+ opt.value = selected;
+ opt.textContent = selected === '0' ? 'Default / Auto' : `Model ${selected}`;
+ select.appendChild(opt);
+ }
+ select.value = selected;
+}
+
+async function loadModelProfiles() {
+ try {
+ const result = await window.ga.getModelProfiles();
+ state.modelProfiles = Array.isArray(result && result.profiles) ? result.profiles : [];
+ renderModelOptions();
+ } catch (err) {
+ addDiagnostic('warn', 'Failed to load model names', err);
+ renderModelOptions();
+ }
+}
+
+function openSettings() {
+ renderModelOptions();
+ const cfg = getActiveConfig();
+ $('cfg-llm').value = String(cfg.llmNo || 0);
+ settingsModal.classList.remove('hidden');
+ loadModelProfiles();
+}
+function closeSettings() { settingsModal.classList.add('hidden'); }
+
+async function openConfigFile(openFn, label) {
+ try {
+ const result = await openFn();
+ if (result && result.ok === false) {
+ showError(`Failed to open ${label}: ${result.error || result.path || 'unknown error'}`);
+ }
+ } catch (err) {
+ showError(`Failed to open ${label}: ${err.message || err}`);
+ }
+}
+
+async function saveSettings() {
+ const saveBtn = $('save-settings');
+ saveBtn.disabled = true;
+ try {
+ const sess = state.sessions.get(state.activeId);
+ if (!sess) throw new Error('No active session');
+ const cfg = sess.config;
+ cfg.llmNo = Math.max(0, parseInt($('cfg-llm').value, 10) || 0);
+ await window.ga.saveConfig(cfg);
+ closeSettings();
+ } catch (err) {
+ showError('Failed to save settings: ' + (err.message || err));
+ } finally {
+ saveBtn.disabled = false;
+ }
+}
+
+async function ensureBridgeSession(sess) {
+ if (!sess) throw new Error('No active session.');
+ if (sess.bridgeSessionId) return sess.bridgeSessionId;
+ const cwd = sess.cwd || await getCwd();
+ const res = await window.ga.rpc('session/new', { cwd, mcp_servers: [] });
+ if (res.error) throw new Error(typeof res.error === 'string' ? res.error : (res.error.message || JSON.stringify(res.error)));
+ sess.bridgeSessionId = res.sessionId;
+ sess.cwd = cwd;
+ return sess.bridgeSessionId;
+}
+
+async function restartBridge(options = {}) {
+ const { remapSessions = false } = options;
+ setStatus('warn', 'Restarting…');
+ state.bridgeReady = false;
+ state.restartingBridge = true;
+ if (remapSessions) {
+ for (const sess of state.sessions.values()) sess.bridgeSessionId = null;
+ }
+ state.bridgeNoticeMessage = showSystem('Bridge restarting…');
+ await window.ga.startBridge(getActiveConfig().llmNo || 0);
+ window.setTimeout(() => {
+ if (state.restartingBridge && !state.bridgeReady && !getActiveSessionRuntime()?.busy) {
+ markBridgeReady('Bridge ready.');
+ addDiagnostic('warn', 'Bridge ready event timeout; restored Ready status locally');
+ }
+ }, 2500);
+}
+
+// ─── Bridge events ───────────────────────────────────────────────────────
+let _bootstrappingSession = false;
+async function markBridgeReady(noticeText = 'Bridge ready.') {
+ if (state.bridgeReady) return; // already marked ready, prevent double-fire
+ state.bridgeReady = true;
+ state.restartingBridge = false;
+ if (getActiveSessionRuntime()?.busy) setStatus('busy', 'Agent is responding…');
+ else setStatus('ok', 'Ready');
+ updateBridgeNotice(noticeText);
+ hideError();
+ // Restore sessions from bridge (survives page refresh) or create first session
+ if (state.sessions.size === 0 && !_bootstrappingSession) {
+ _bootstrappingSession = true;
+ try {
+ // Try to restore existing sessions from bridge
+ const bridgeUrl = window.ga.bridgeUrl || 'http://127.0.0.1:14168';
+ const listRes = await fetch(`${bridgeUrl}/sessions`).then(r => r.json()).catch(() => null);
+ const existingSessions = listRes?.sessions || [];
+ if (existingSessions.length > 0) {
+ // Restore each session from bridge
+ for (const bSess of existingSessions) {
+ const localId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const sess = createLocalSession(localId, bSess.title || 'Restored', bSess.id || bSess.sessionId);
+ // Fetch full messages for this session
+ const sid = bSess.id || bSess.sessionId;
+ const msgRes = await fetch(`${bridgeUrl}/session/${sid}/messages?after=0&limit=9999`).then(r => r.json()).catch(() => null);
+ if (msgRes?.messages) {
+ sess.messages = msgRes.messages;
+ // Initialize polling state so we don't re-fetch these messages
+ const runtime = getSessionRuntime(sess);
+ runtime.seenBridgeMessageIds = new Set();
+ let maxId = 0;
+ for (const m of msgRes.messages) {
+ if (m.id) { runtime.seenBridgeMessageIds.add(Number(m.id)); maxId = Math.max(maxId, Number(m.id)); }
+ }
+ runtime.lastPolledMessageId = maxId;
+ }
+ }
+ // Activate the first session
+ const firstLocalId = [...state.sessions.keys()][0];
+ if (firstLocalId) setActiveSession(firstLocalId);
+ } else {
+ await newSession();
+ }
+ } finally { _bootstrappingSession = false; }
+ }
+ updateSendButton();
+ // Refresh model profiles from bridge (authoritative source)
+ loadModelProfiles();
+}
+
+window.ga.onBridgeReady(() => {
+ markBridgeReady();
+});
+
+window.ga.onBridgeMessage(() => {
+ // RPC responses are resolved in main; renderer readiness comes from bridge-ready.
+});
+
+window.ga.onBridgeNotification((msg) => {
+ handleNotification(msg);
+});
+
+window.ga.onBridgeError((err) => {
+ console.error('Bridge error:', err);
+ addDiagnostic('error', 'Bridge error', err);
+ setStatus('err', 'Error');
+ state.bridgeReady = false;
+ state.restartingBridge = false;
+
+ if (err.type === 'no-mykey') {
+ showError(err.message, 'Setup', async () => {
+ await window.ga.openMykeyTemplate();
+ }, { skipDiagnostic: true });
+ } else if (err.type === 'no-python') {
+ showError(err.message, 'Settings', openSettings, { skipDiagnostic: true });
+ } else {
+ showError(err.message || 'Bridge error', null, null, { skipDiagnostic: true });
+ }
+});
+
+window.ga.onBridgeClosed((info) => {
+ addDiagnostic('warn', 'Bridge closed', info);
+ if (state.restartingBridge) {
+ setStatus('warn', 'Restarting…');
+ return;
+ }
+ state.bridgeReady = false;
+ // Clear busy flag on all sessions so pending poll loops can exit cleanly
+ for (const [sid, runtime] of state.runtimeBySessionId) {
+ if (runtime.busy) setBusy(false, null, state.sessions.get(sid));
+ }
+ setStatus('err', `Bridge stopped (${info.code})`);
+});
+
+window.ga.onBridgeLog((text) => {
+ console.log('[bridge]', text);
+ addDiagnostic('info', 'Bridge log', text);
+});
+
+// ─── Input handling ──────────────────────────────────────────────────────
+inputEl.addEventListener('input', () => {
+ // auto-resize
+ inputEl.style.height = 'auto';
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + 'px';
+ updateSendButton();
+});
+
+// IME composition fix - triple guard for CJK input methods (macOS especially)
+let _imeComposing = false;
+inputEl.addEventListener('compositionstart', () => { _imeComposing = true; });
+inputEl.addEventListener('compositionend', () => { _imeComposing = false; });
+
+inputEl.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ if (e.isComposing || _imeComposing || e.keyCode === 229) return; // IME active, ignore
+ e.preventDefault();
+ submitInput();
+ } else if (e.key === 'Escape' && getActiveSessionRuntime()?.busy) {
+ e.preventDefault();
+ cancelPrompt();
+ }
+});
+
+// ─── Image paste handling ─────────────────────────────────────────────────
+const imagePreviews = document.getElementById('image-previews');
+const pendingImages = []; // Array of { dataUrl, id }
+
+inputEl.addEventListener('paste', (e) => {
+ const items = e.clipboardData?.items;
+ if (!items) return;
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ e.preventDefault();
+ const file = item.getAsFile();
+ if (!file) continue;
+ const reader = new FileReader();
+ reader.onload = () => {
+ const dataUrl = reader.result;
+ const id = `img-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
+ pendingImages.push({ dataUrl, id });
+ renderImagePreviews();
+ };
+ reader.readAsDataURL(file);
+ break; // handle one image per paste
+ }
+ }
+});
+
+function renderImagePreviews() {
+ imagePreviews.innerHTML = '';
+ for (const img of pendingImages) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'image-preview-item';
+ wrapper.dataset.imgId = img.id;
+
+ const imgEl = document.createElement('img');
+ imgEl.src = img.dataUrl;
+ imgEl.alt = 'Pasted image';
+
+ const closeBtn = document.createElement('button');
+ closeBtn.className = 'remove-img';
+ closeBtn.textContent = '×';
+ closeBtn.setAttribute('aria-label', 'Remove image');
+ closeBtn.addEventListener('click', () => {
+ const idx = pendingImages.findIndex(i => i.id === img.id);
+ if (idx !== -1) pendingImages.splice(idx, 1);
+ renderImagePreviews();
+ });
+
+ wrapper.appendChild(imgEl);
+ wrapper.appendChild(closeBtn);
+ imagePreviews.appendChild(wrapper);
+ }
+ imagePreviews.style.display = pendingImages.length ? 'flex' : 'none';
+}
+
+function clearPendingImages() {
+ pendingImages.length = 0;
+ renderImagePreviews();
+}
+
+function submitInput() {
+ const text = inputEl.value.trim();
+ if (!text && pendingImages.length === 0) return;
+ if (getActiveSessionRuntime()?.busy) {
+ showSystem('Agent is still responding. Press Esc or Stop before sending another message.');
+ return;
+ }
+ const images = [...pendingImages];
+ inputEl.value = '';
+ inputEl.style.height = 'auto';
+ clearPendingImages();
+ updateSendButton();
+
+ if (text.startsWith('/')) {
+ handleSlash(text).catch((err) => {
+ showSystem('Command failed: ' + (err.message || err));
+ });
+ } else {
+ sendPrompt(text, images);
+ }
+}
+
+sendBtn.addEventListener('click', () => {
+ if (getActiveSessionRuntime()?.busy) {
+ cancelPrompt().then((ok) => {
+ if (ok) showSystem('Stop requested.');
+ });
+ } else submitInput();
+});
+
+// ─── Buttons ─────────────────────────────────────────────────────────────
+$('new-session-btn').addEventListener('click', newSession);
+$('settings-btn').addEventListener('click', openSettings);
+$('close-settings').addEventListener('click', closeSettings);
+$('cancel-settings').addEventListener('click', closeSettings);
+$('save-settings').addEventListener('click', saveSettings);
+$('open-mykey').addEventListener('click', () => openConfigFile(window.ga.openMykey, 'mykey.py'));
+$('error-dismiss').addEventListener('click', hideError);
+
+settingsModal.querySelector('.modal-backdrop').addEventListener('click', closeSettings);
+
+// ─── Message Search (Cmd/Ctrl+F) ─────────────────────────────────────────
+(function initSearch() {
+ const searchBar = document.getElementById('search-bar');
+ const searchInput = document.getElementById('search-input');
+ const searchClose = document.getElementById('search-close');
+ const searchPrev = document.getElementById('search-prev');
+ const searchNext = document.getElementById('search-next');
+ const searchCount = document.getElementById('search-count');
+
+ let highlights = [];
+ let currentIdx = -1;
+
+ function openSearch() {
+ searchBar.classList.remove('hidden');
+ searchBar.classList.add('visible');
+ searchInput.focus();
+ searchInput.select();
+ }
+
+ function closeSearch() {
+ searchBar.classList.remove('visible');
+ searchBar.classList.add('hidden');
+ clearHighlights();
+ searchInput.value = '';
+ searchCount.textContent = '';
+ }
+
+ function clearHighlights() {
+ highlights.forEach(el => {
+ const parent = el.parentNode;
+ if (parent) {
+ parent.replaceChild(document.createTextNode(el.textContent), el);
+ parent.normalize();
+ }
+ });
+ highlights = [];
+ currentIdx = -1;
+ }
+
+ function doSearch(query) {
+ clearHighlights();
+ if (!query) { searchCount.textContent = ''; return; }
+
+ const chatArea = document.getElementById('messages');
+ const walker = document.createTreeWalker(chatArea, NodeFilter.SHOW_TEXT, null);
+ const textNodes = [];
+ while (walker.nextNode()) textNodes.push(walker.currentNode);
+
+ const lowerQ = query.toLowerCase();
+ textNodes.forEach(node => {
+ const text = node.textContent;
+ const lower = text.toLowerCase();
+ let idx = lower.indexOf(lowerQ);
+ if (idx === -1) return;
+
+ const frag = document.createDocumentFragment();
+ let lastIdx = 0;
+ while (idx !== -1) {
+ frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
+ const mark = document.createElement('mark');
+ mark.className = 'search-highlight';
+ mark.textContent = text.slice(idx, idx + query.length);
+ frag.appendChild(mark);
+ highlights.push(mark);
+ lastIdx = idx + query.length;
+ idx = lower.indexOf(lowerQ, lastIdx);
+ }
+ frag.appendChild(document.createTextNode(text.slice(lastIdx)));
+ node.parentNode.replaceChild(frag, node);
+ });
+
+ searchCount.textContent = highlights.length ? `1/${highlights.length}` : '0';
+ if (highlights.length) { currentIdx = 0; scrollToHighlight(); }
+ }
+
+ function scrollToHighlight() {
+ highlights.forEach((el, i) => el.classList.toggle('active', i === currentIdx));
+ if (highlights[currentIdx]) {
+ // Expand any collapsed ancestor turns so the match is visible
+ let ancestor = highlights[currentIdx].parentElement;
+ while (ancestor && ancestor !== document.body) {
+ if (ancestor.classList.contains('turn') && ancestor.classList.contains('collapsed')) {
+ ancestor.classList.remove('collapsed');
+ }
+ ancestor = ancestor.parentElement;
+ }
+ highlights[currentIdx].scrollIntoView({ block: 'center', behavior: 'smooth' });
+ searchCount.textContent = `${currentIdx + 1}/${highlights.length}`;
+ }
+ }
+
+ function nextMatch() { if (!highlights.length) return; currentIdx = (currentIdx + 1) % highlights.length; scrollToHighlight(); }
+ function prevMatch() { if (!highlights.length) return; currentIdx = (currentIdx - 1 + highlights.length) % highlights.length; scrollToHighlight(); }
+
+ // Event listeners
+ searchClose.addEventListener('click', closeSearch);
+ searchPrev.addEventListener('click', prevMatch);
+ searchNext.addEventListener('click', nextMatch);
+
+ let searchTimeout;
+ searchInput.addEventListener('input', () => {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(() => doSearch(searchInput.value), 200);
+ });
+ searchInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') { e.shiftKey ? prevMatch() : nextMatch(); e.preventDefault(); }
+ if (e.key === 'Escape') { closeSearch(); e.preventDefault(); }
+ });
+
+ // Global shortcut: Cmd+F (Mac) / Ctrl+F (Win/Linux)
+ // Note: On macOS Electron intercepts Cmd+F via menu accelerator,
+ // so we also listen for IPC 'open-search' from main process.
+ document.addEventListener('keydown', (e) => {
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
+ const mod = isMac ? e.metaKey : e.ctrlKey;
+ if (mod && e.key === 'f') {
+ e.preventDefault();
+ openSearch();
+ }
+ if (e.key === 'Escape' && searchBar.classList.contains('visible')) {
+ e.preventDefault();
+ closeSearch();
+ }
+ });
+
+ // Listen for IPC from main process (menu accelerator on macOS)
+ if (window.ga && window.ga.onOpenSearch) {
+ window.ga.onOpenSearch(() => openSearch());
+ }
+})();
+
+// ─── Init ────────────────────────────────────────────────────────────────
+(async function init() {
+ // Add platform class to body for platform-specific CSS
+ const platform = (window.ga && window.ga.platform) || process.platform || 'unknown';
+ document.body.classList.add('platform-' + platform);
+
+ try {
+ const saved = await window.ga.getConfig();
+ Object.assign(state.defaultConfig, saved);
+ } catch (err) {
+ addDiagnostic('error', 'Failed to load settings', err);
+ showError('Failed to load settings; using defaults: ' + (err.message || err));
+ }
+ applyTheme();
+ await loadModelProfiles();
+ updateSendButton();
+ inputEl.focus();
+})();
diff --git a/frontends/desktop/static/fallback.html b/frontends/desktop/static/fallback.html
new file mode 100644
index 000000000..6c28749aa
--- /dev/null
+++ b/frontends/desktop/static/fallback.html
@@ -0,0 +1,91 @@
+
+
+
+
+GenericAgent — Setup
+
+
+
+
+
⚙️ Setup Required
+
The backend could not start. Please configure the paths below.
+
后端启动失败,请配置以下路径。
+
+
Python interpreter 解释器路径
+
+
+
+
+ e.g. C:/Python312/python.exe / ~/miniconda3/envs/myenv/bin/python
+
+
+
Project directory 项目目录
+
+
+
+
+ The folder containing frontends/desktop_bridge.py
+
+
+
Start 启动
+
+
+
+
+
diff --git a/frontends/desktop/static/ga-web.js b/frontends/desktop/static/ga-web.js
new file mode 100644
index 000000000..960f0b922
--- /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 000000000..e86ba27ee
--- /dev/null
+++ b/frontends/desktop/static/index.html
@@ -0,0 +1,116 @@
+
+
+
+
+ GenericAgent
+
+
+
+
+
+
+
+
+
+
+
+ ▲
+ ▼
+ ✕
+
+
+
+
+
New task
+
Task me anything. Type /help for commands.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Model
+
+ Default / Auto
+
+ Select the LLM profile from mykey.py.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✕
+
+
+
+
+
+
+
+
diff --git a/frontends/desktop/static/loading.html b/frontends/desktop/static/loading.html
new file mode 100644
index 000000000..a99b0ede7
--- /dev/null
+++ b/frontends/desktop/static/loading.html
@@ -0,0 +1,14 @@
+
+
+
+
+GenericAgent
+
+
+
+
diff --git a/frontends/desktop/static/styles.css b/frontends/desktop/static/styles.css
new file mode 100644
index 000000000..f2659d437
--- /dev/null
+++ b/frontends/desktop/static/styles.css
@@ -0,0 +1,896 @@
+/* GenericAgent Desktop — Minimal Codex-inspired styling */
+
+:root {
+ --bg: #ffffff;
+ --bg-subtle: #fafafa;
+ --bg-hover: #f3f3f3;
+ --bg-active: #ededed;
+ --border: #e8e8e8;
+ --border-strong: #d4d4d4;
+ --text: #0a0a0a;
+ --text-muted: #6b6b6b;
+ --text-faint: #9a9a9a;
+ --accent: #0a0a0a;
+ --accent-text: #ffffff;
+ --user-bg: #f5f5f5;
+ --code-bg: #f5f5f5;
+ --code-text: #1a1a1a;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.04);
+ --shadow-strong: 0 4px 24px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.05);
+ --radius: 10px;
+ --radius-sm: 6px;
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
+ --font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Helvetica, Arial, sans-serif;
+ --font-mono: "SF Mono", "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ --ok: #16a34a;
+ --warn: #f59e0b;
+ --err: #dc2626;
+}
+
+[data-theme="dark"] {
+ --bg: #1a1a1a;
+ --bg-subtle: #1f1f1f;
+ --bg-hover: #262626;
+ --bg-active: #2d2d2d;
+ --border: #2a2a2a;
+ --border-strong: #3a3a3a;
+ --text: #ededed;
+ --text-muted: #a0a0a0;
+ --text-faint: #6b6b6b;
+ --accent: #ededed;
+ --accent-text: #0a0a0a;
+ --user-bg: #262626;
+ --code-bg: #0f0f0f;
+ --code-text: #e0e0e0;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.04);
+ --shadow-strong: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);
+}
+
+@media (prefers-color-scheme: dark) {
+ [data-theme="auto"] {
+ --bg: #1a1a1a;
+ --bg-subtle: #1f1f1f;
+ --bg-hover: #262626;
+ --bg-active: #2d2d2d;
+ --border: #2a2a2a;
+ --border-strong: #3a3a3a;
+ --text: #ededed;
+ --text-muted: #a0a0a0;
+ --text-faint: #6b6b6b;
+ --accent: #ededed;
+ --accent-text: #0a0a0a;
+ --user-bg: #262626;
+ --code-bg: #0f0f0f;
+ --code-text: #e0e0e0;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.04);
+ --shadow-strong: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);
+ }
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0; padding: 0; height: 100%;
+ font-family: var(--font);
+ font-size: 14px;
+ letter-spacing: -0.01em;
+ color: var(--text);
+ background: var(--bg);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ overflow: hidden;
+}
+
+#app {
+ display: flex;
+ height: 100vh;
+ width: 100vw;
+}
+
+/* ─── Top tabs ───────────────────────────────────────────────────────── */
+button, input, textarea, select, .icon-btn, .btn, .send-btn, .session-tab, .tab-new-btn {
+ -webkit-app-region: no-drag;
+}
+
+.tab-strip {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: flex-end;
+ gap: 2px;
+ overflow: hidden;
+ padding: 8px 0 0 0;
+ scrollbar-width: none;
+}
+/* macOS: leave space for traffic-light buttons */
+body.platform-darwin .tab-strip {
+ padding-left: 80px;
+}
+.tab-strip::-webkit-scrollbar { display: none; }
+
+/* Chrome-like chat tabs */
+.session-tab {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex: 0 1 180px;
+ min-width: 60px;
+ height: 32px;
+ padding: 0 6px 0 10px;
+ border: 1px solid transparent;
+ border-bottom: 0;
+ border-radius: 10px 10px 0 0;
+ background: transparent;
+ color: var(--text-muted);
+ font: inherit;
+ font-size: 13px;
+ text-align: left;
+ white-space: nowrap;
+ overflow: hidden;
+ cursor: pointer;
+}
+.session-tab .tab-label {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.session-tab .tab-dot {
+ flex-shrink: 0;
+ width: 7px; height: 7px;
+ border-radius: 50%;
+ background: var(--ok);
+ margin-right: 2px;
+}
+.session-tab .tab-dot.busy { background: var(--warn); animation: pulse 1.2s infinite; }
+.session-tab .tab-dot.err { background: var(--err); }
+.session-tab .tab-dot.warn { background: var(--warn); }
+.session-tab .tab-close {
+ flex-shrink: 0;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ line-height: 1;
+ color: var(--text-faint);
+ opacity: 0;
+ transition: opacity 0.1s, background 0.1s;
+ cursor: pointer;
+}
+.session-tab:hover .tab-close { opacity: 1; }
+.session-tab .tab-close:hover {
+ background: var(--bg-active);
+ color: var(--text);
+}
+.session-tab:hover { background: var(--bg-hover); color: var(--text); }
+.session-tab.active {
+ background: var(--bg);
+ border-color: var(--border);
+ color: var(--text);
+ font-weight: 600;
+}
+.session-tab.active .tab-close { opacity: 0.6; }
+.session-tab.active .tab-close:hover { opacity: 1; background: var(--bg-active); }
+
+.tab-new-btn {
+ width: 28px;
+ height: 28px;
+ margin: 0 8px 2px 2px;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--text-muted);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex-shrink: 0;
+ align-self: flex-end;
+}
+.tab-new-btn:hover { background: var(--bg-hover); color: var(--text); }
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* ─── Main ────────────────────────────────────────────────────────────── */
+#main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ background: var(--bg);
+}
+
+#topbar {
+ height: 44px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: stretch;
+ justify-content: space-between;
+ padding: 0 12px 0 0;
+ -webkit-app-region: drag;
+ flex-shrink: 0;
+ background: var(--bg-subtle);
+}
+
+.topbar-left, .topbar-right {
+ display: flex;
+ align-items: center;
+ gap: 0;
+}
+.topbar-right { display: none; }
+.topbar-left { flex: 1; min-width: 0; align-items: stretch; }
+.topbar-right { flex-shrink: 0; }
+
+#session-title {
+ font-size: 13px;
+ font-weight: 600;
+ margin: 0;
+ letter-spacing: -0.01em;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 8px;
+ border-radius: 10px;
+ background: var(--bg-subtle);
+ border: 1px solid var(--border);
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.status-dot {
+ width: 6px; height: 6px;
+ border-radius: 50%;
+ background: var(--text-faint);
+}
+.badge.ok .status-dot { background: var(--ok); }
+.badge.warn .status-dot { background: var(--warn); }
+.badge.err .status-dot { background: var(--err); }
+.badge.busy .status-dot { background: var(--warn); animation: pulse 1.2s infinite; }
+
+@keyframes pulse { 50% { opacity: 0.3; } }
+
+/* ─── Messages ─────────────────────────────────────────────────────────── */
+#messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px 20px 8px;
+}
+
+#messages.empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.empty-state {
+ text-align: center;
+ color: var(--text-muted);
+ max-width: 400px;
+}
+.empty-logo {
+ font-size: 36px;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+ letter-spacing: 0.1em;
+}
+.empty-title {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: 4px;
+}
+.empty-sub {
+ font-size: 13px;
+}
+.empty-sub code {
+ background: var(--bg-subtle);
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+}
+
+.msg {
+ max-width: 780px;
+ margin: 0 auto 18px;
+ font-size: 14.5px;
+ line-height: 1.66;
+}
+
+.msg-user {
+ display: flex;
+ justify-content: flex-end;
+}
+.msg-user .bubble {
+ background: transparent;
+ padding: 0;
+ border-radius: 0;
+ max-width: 85%;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ color: var(--text);
+ font-weight: 500;
+}
+
+.msg-assistant {
+ color: var(--text);
+}
+.assistant-response {
+ padding: 0;
+ color: var(--text);
+}
+
+.msg-system {
+ color: var(--text-faint);
+ font-size: 12px;
+ text-align: left;
+ padding: 6px 10px;
+ font-family: var(--font-mono);
+ white-space: pre-wrap;
+}
+
+.msg-error {
+ color: var(--err);
+ background: rgba(220, 38, 38, 0.06);
+ border-left: 3px solid var(--err);
+ padding: 8px 14px;
+ border-radius: 4px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ white-space: pre-wrap;
+}
+
+/* Turn (collapsible segment of assistant reply) */
+.turn {
+ margin: 6px 0 10px;
+ border: 0;
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ background: transparent;
+}
+.msg-assistant .turn[data-kind="agent_message_chunk"] {
+ border: 0;
+ background: transparent;
+}
+.turn-header {
+ width: max-content;
+ max-width: 100%;
+ padding: 4px 8px;
+ border-radius: 999px;
+ background: var(--bg-subtle);
+ border: 1px solid var(--border);
+ font-size: 12px;
+ color: var(--text-muted);
+ cursor: pointer;
+ user-select: none;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font);
+ overflow: hidden;
+ white-space: nowrap;
+}
+.turn-header:hover { background: var(--bg-hover); }
+.turn-caret {
+ display: inline-block;
+ transition: transform 0.15s;
+ font-size: 10px;
+}
+.turn.collapsed .turn-caret { transform: rotate(-90deg); }
+.turn.collapsed .turn-body { display: none; }
+
+.turn-body {
+ padding: 8px 0 0 18px;
+ color: var(--text-muted);
+}
+
+.turn-tag {
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ color: var(--text-faint);
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.turn-status {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-left: auto;
+}
+.tool-detail-turn {
+ margin: 0 0 8px;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--surface-raised) 82%, transparent);
+}
+.tool-detail-header {
+ min-height: 28px;
+ padding: 5px 8px;
+}
+.tool-detail-body {
+ padding: 8px;
+}
+.tool-detail-body pre {
+ margin: 0;
+ max-height: 240px;
+ overflow: auto;
+}
+.turn-rendered-md:empty { display: none; }
+
+/* Markdown content */
+.md {
+ line-height: 1.72;
+ letter-spacing: -0.006em;
+}
+.md p { margin: 0 0 10px; }
+.md p:last-child { margin-bottom: 0; }
+.md h1, .md h2, .md h3 { margin: 16px 0 8px; font-family: var(--font-display); font-weight: 600; letter-spacing: -0.02em; }
+.md h1 { font-size: 18px; }
+.md h2 { font-size: 16px; }
+.md h3 { font-size: 14px; }
+.md ul, .md ol { margin: 0 0 10px; padding-left: 22px; }
+.md li { margin: 2px 0; }
+.md code {
+ font-family: var(--font-mono);
+ font-size: 12.5px;
+ background: var(--code-bg);
+ padding: 1px 5px;
+ border-radius: 3px;
+}
+.md pre {
+ background: var(--code-bg);
+ padding: 10px 12px;
+ border-radius: var(--radius-sm);
+ overflow-x: auto;
+ margin: 8px 0;
+ border: 1px solid var(--border);
+}
+.md pre code {
+ background: transparent;
+ padding: 0;
+ color: var(--code-text);
+ font-size: 12.5px;
+ line-height: 1.5;
+}
+.md blockquote {
+ border-left: 3px solid var(--border-strong);
+ padding-left: 12px;
+ color: var(--text-muted);
+ margin: 8px 0;
+}
+.md a { color: var(--text); text-decoration: underline; }
+.md hr { border: 0; border-top: 1px solid var(--border); margin: 12px 0; }
+.md table { border-collapse: collapse; margin: 8px 0; }
+.md th, .md td { border: 1px solid var(--border); padding: 4px 8px; font-size: 12.5px; }
+
+/* Streaming cursor */
+.cursor {
+ display: inline-block;
+ width: 6px;
+ height: 1em;
+ background: var(--text-muted);
+ vertical-align: text-bottom;
+ margin-left: 2px;
+ animation: blink 1s steps(2) infinite;
+}
+@keyframes blink { 50% { opacity: 0; } }
+
+/* ─── Composer ─────────────────────────────────────────────────────────── */
+#composer-wrap {
+ padding: 8px 20px 16px;
+ background: var(--bg);
+}
+
+#composer {
+ max-width: 780px;
+ margin: 0 auto;
+ background: var(--bg);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+#composer:focus-within {
+ border-color: var(--accent);
+ box-shadow: var(--shadow-strong);
+}
+
+#input {
+ width: 100%;
+ border: 0;
+ outline: 0;
+ resize: none;
+ padding: 12px 14px 4px;
+ background: transparent;
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 14px;
+ line-height: 1.5;
+ max-height: 200px;
+}
+#input::placeholder { color: var(--text-faint); }
+
+/* Image previews in composer */
+.image-previews {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+ gap: 8px;
+ padding: 8px 12px 0;
+}
+.image-previews:empty { display: none; }
+.image-preview-item {
+ position: relative;
+ width: 64px;
+ height: 64px;
+ border-radius: 6px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+}
+.image-preview-item img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.image-preview-item .remove-img {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: rgba(0,0,0,0.6);
+ color: #fff;
+ border: 0;
+ font-size: 12px;
+ line-height: 18px;
+ text-align: center;
+ cursor: pointer;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.image-preview-item .remove-img:hover {
+ background: rgba(0,0,0,0.85);
+}
+
+/* User message image thumbnails */
+.user-images {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 6px;
+ justify-content: flex-end;
+}
+.user-msg-thumb {
+ width: 80px;
+ height: 80px;
+ object-fit: cover;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+}
+
+.composer-bottom {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 4px 8px 8px 14px;
+}
+
+.composer-hints {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.composer-hints .icon-btn.ghost {
+ font-size: 11px;
+ padding: 3px 7px;
+ gap: 4px;
+}
+.hint-label {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.send-btn {
+ width: 30px; height: 30px;
+ border: 0;
+ border-radius: 6px;
+ background: var(--accent);
+ color: var(--accent-text);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: opacity 0.15s, transform 0.1s;
+}
+.send-btn:hover { opacity: 0.85; }
+.send-btn:active { transform: scale(0.96); }
+.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.send-btn.stop { background: var(--err); }
+
+/* ─── Buttons ──────────────────────────────────────────────────────────── */
+.icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 6px 10px;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 13px;
+ font-family: var(--font);
+ transition: background 0.15s;
+}
+.icon-btn:hover { background: var(--bg-hover); }
+.icon-btn.ghost {
+ border: 0;
+ background: transparent;
+ color: var(--text-muted);
+}
+.icon-btn.ghost:hover { background: var(--bg-hover); color: var(--text); }
+.icon-btn.full { width: 100%; justify-content: flex-start; }
+
+.btn {
+ padding: 7px 14px;
+ border: 1px solid var(--border-strong);
+ background: var(--bg);
+ color: var(--text);
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+ cursor: pointer;
+ font-family: var(--font);
+}
+.btn:hover { background: var(--bg-hover); }
+.btn.primary {
+ background: var(--accent);
+ color: var(--accent-text);
+ border-color: var(--accent);
+}
+.btn.primary:hover { opacity: 0.88; }
+.btn.ghost { border: 0; background: transparent; }
+
+.hidden { display: none !important; }
+
+/* ─── Modal ────────────────────────────────────────────────────────────── */
+.modal {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+}
+.modal-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.25);
+ backdrop-filter: blur(2px);
+}
+.modal-body {
+ position: relative;
+ background: var(--bg);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-strong);
+ width: 460px;
+ max-width: 90vw;
+ max-height: 88vh;
+ display: flex;
+ flex-direction: column;
+}
+.modal-header {
+ padding: 14px 18px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--border);
+}
+.modal-header h2 { margin: 0; font-size: 15px; font-weight: 600; }
+.modal-content { padding: 16px 18px; overflow-y: auto; }
+.modal-footer {
+ padding: 12px 18px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.form-row {
+ margin-bottom: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.form-row label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text);
+}
+.form-row input, .form-row select {
+ padding: 7px 10px;
+ background: var(--bg-subtle);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 13px;
+ outline: 0;
+ transition: border-color 0.15s;
+}
+.form-row input:focus, .form-row select:focus {
+ border-color: var(--border-strong);
+}
+.form-row small {
+ color: var(--text-faint);
+ font-size: 11px;
+}
+.form-row small code {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ background: var(--bg-subtle);
+ padding: 1px 4px;
+ border-radius: 3px;
+}
+.input-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.input-row input { min-width: 0; }
+.input-row .btn { flex: 0 0 auto; }
+.btn-row { display: flex; gap: 8px; }
+
+
+/* ─── Diagnostics panel ────────────────────────────────────────────────── */
+.diagnostics-panel {
+ position: fixed;
+ top: 56px;
+ right: 16px;
+ width: min(520px, calc(100vw - 32px));
+ max-height: calc(100vh - 88px);
+ background: var(--bg);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-strong);
+ z-index: 60;
+ display: flex;
+ flex-direction: column;
+}
+.diagnostics-header {
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+.diagnostics-header h2 { margin: 0; font-size: 14px; font-weight: 600; }
+.diagnostics-header p { margin: 3px 0 0; color: var(--text-faint); font-size: 11px; }
+.diagnostics-log {
+ margin: 0;
+ padding: 12px 14px;
+ min-height: 180px;
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ background: var(--code-bg);
+ color: var(--code-text);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ line-height: 1.45;
+}
+.diagnostics-footer {
+ padding: 10px 12px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+/* ─── Error banner ─────────────────────────────────────────────────────── */
+.error-banner {
+ position: fixed;
+ top: 56px;
+ right: 16px;
+ max-width: 420px;
+ background: var(--bg);
+ border: 1px solid var(--err);
+ border-left: 3px solid var(--err);
+ border-radius: var(--radius-sm);
+ padding: 10px 14px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 12.5px;
+ box-shadow: var(--shadow-strong);
+ z-index: 50;
+}
+
+/* Scrollbars (WebKit) */
+::-webkit-scrollbar { width: 10px; height: 10px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb {
+ background: var(--border-strong);
+ border-radius: 5px;
+ border: 2px solid var(--bg);
+}
+::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
+
+
+/* Structured GA action cards */
+.structured-turn { margin: 8px 0; }
+.structured-turn .turn-header { width: fit-content; max-width: min(100%, 760px); background: rgba(248, 248, 250, 0.92); }
+.turn-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font-size: 12px; }
+.structured-turn .turn-body { margin-top: 6px; padding: 10px 12px; border-left: 2px solid var(--border); color: var(--text); }
+
+.task-elapsed { display: block; width: max-content; color: var(--accent); font-size: 12px; font-weight: 600; margin: 0 0 6px 0; }
+.tool-detail-turn { margin: 8px 0 0 16px; border-left: 2px solid var(--accent); padding-left: 8px; }
+.tool-detail-body .tool-args-code { margin: 6px 0 0; white-space: pre-wrap; }
+
+.summary-hint { font-style: italic; color: var(--text-muted, #888); opacity: 0.7; margin-bottom: 4px; font-size: 0.9em; }
+.think-hint { font-style: italic; color: var(--text-muted, #888); opacity: 0.6; margin-bottom: 4px; font-size: 0.85em; }
+
+
+/* ─── Copy Button for code blocks / pre blocks ─── */
+pre { position: relative; }
+pre .copy-btn {
+ position: absolute; top: 6px; right: 6px;
+ background: rgba(128,128,128,0.15); border: 1px solid rgba(128,128,128,0.25);
+ border-radius: 4px; padding: 2px 8px; font-size: 12px;
+ color: var(--text-muted, #888); cursor: pointer;
+ opacity: 0; transition: opacity 0.2s;
+ z-index: 2; user-select: none;
+}
+pre:hover .copy-btn { opacity: 1; }
+pre .copy-btn:hover { background: rgba(128,128,128,0.3); color: var(--text, #333); }
+pre .copy-btn.copied { color: #4caf50; border-color: #4caf50; opacity: 1; }
+
+/* ─── Search Bar ─────────────────────────────────────────────────────── */
+#search-bar {
+ display: none; position: fixed; top: 40px; right: 16px;
+ background: var(--bg, #fff); border: 1px solid var(--border, #ddd);
+ border-radius: 8px; padding: 6px 10px; gap: 6px; align-items: center;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 9999;
+ font-size: 13px;
+}
+#search-bar.visible { display: flex; }
+#search-input {
+ border: 1px solid var(--border, #ccc); border-radius: 4px;
+ padding: 4px 8px; font-size: 13px; width: 200px;
+ background: var(--input-bg, #fff); color: var(--text, #333);
+ outline: none;
+}
+#search-input:focus { border-color: var(--accent, #4a9eff); }
+#search-count { min-width: 40px; text-align: center; color: var(--text-muted, #888); font-size: 12px; }
+#search-bar button {
+ background: none; border: none; cursor: pointer; font-size: 16px;
+ color: var(--text, #333); padding: 2px 6px; border-radius: 4px;
+}
+#search-bar button:hover { background: rgba(128,128,128,0.15); }
+mark.search-highlight { background: #ffeb3b; color: #000; border-radius: 2px; padding: 0 1px; }
+mark.search-highlight.active { background: #ff9800; color: #fff; }
+
+/* ─── Tab Drag ───────────────────────────────────────────────────────── */
+.tab-btn.drag-over-left { box-shadow: inset 3px 0 0 var(--accent, #4a9eff); }
+.tab-btn.drag-over-right { box-shadow: inset -3px 0 0 var(--accent, #4a9eff); }
diff --git a/frontends/desktop/static/vendor/marked.min.js b/frontends/desktop/static/vendor/marked.min.js
new file mode 100644
index 000000000..4052d1b49
--- /dev/null
+++ b/frontends/desktop/static/vendor/marked.min.js
@@ -0,0 +1,6 @@
+/**
+ * marked v15.0.7 - a markdown parser
+ * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/markedjs/marked
+ */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,c=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),h=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),p=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,u=/(?!\s*\])(?:\\.|[^\[\]\\])+/,g=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",u).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),k=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),d="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",f=/|$))/,x=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",f).replace("tag",d).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),b=r(p).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex(),w={blockquote:r(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",b).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:g,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:l,html:x,lheading:c,list:k,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:b,table:s,text:/^[^\n]+/},m=r("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex(),y={...w,lheading:h,table:m,paragraph:r(p).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",m).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex()},$={...w,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| \\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",f).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:r(p).replace("hr",l).replace("heading"," *#{1,6} *[^\n]").replace("lheading",c).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},R=/^( {2,}|\\)\n(?!\s*$)/,S=/[\p{P}\p{S}]/u,T=/[\s\p{P}\p{S}]/u,z=/[^\s\p{P}\p{S}]/u,A=r(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,T).getRegex(),_=/(?!~)[\p{P}\p{S}]/u,P=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,I=r(P,"u").replace(/punct/g,S).getRegex(),L=r(P,"u").replace(/punct/g,_).getRegex(),B="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",C=r(B,"gu").replace(/notPunctSpace/g,z).replace(/punctSpace/g,T).replace(/punct/g,S).getRegex(),q=r(B,"gu").replace(/notPunctSpace/g,/(?:[^\s\p{P}\p{S}]|~)/u).replace(/punctSpace/g,/(?!~)[\s\p{P}\p{S}]/u).replace(/punct/g,_).getRegex(),E=r("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,z).replace(/punctSpace/g,T).replace(/punct/g,S).getRegex(),Z=r(/\\(punct)/,"gu").replace(/punct/g,S).getRegex(),v=r(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),D=r(f).replace("(?:--\x3e|$)","--\x3e").getRegex(),M=r("^comment|^[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",D).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),O=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Q=r(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",O).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),j=r(/^!?\[(label)\]\[(ref)\]/).replace("label",O).replace("ref",u).getRegex(),N=r(/^!?\[(ref)\](?:\[\])?/).replace("ref",u).getRegex(),G={_backpedal:s,anyPunctuation:Z,autolink:v,blockSkip:/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,br:R,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:s,emStrongLDelim:I,emStrongRDelimAst:C,emStrongRDelimUnd:E,escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,link:Q,nolink:N,punctuation:A,reflink:j,reflinkSearch:r("reflink|nolink(?!\\()","g").replace("reflink",j).replace("nolink",N).getRegex(),tag:M,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},V=e=>K[e];function W(e,t){if(t){if(i.escapeTest.test(e))return e.replace(i.escapeReplace,V)}else if(i.escapeTestNoEncode.test(e))return e.replace(i.escapeReplaceNoEncode,V);return e}function Y(e){try{e=encodeURI(e).replace(i.percentDecode,"%")}catch{return null}return e}function ee(e,t){const n=e.replace(i.findPipe,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(i.splitPipe);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:te(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(null===s)return t;const r=s[1];return t.split("\n").map((e=>{const t=e.match(n.other.beginningSpace);if(null===t)return e;const[s]=t;return s.length>=r.length?e.slice(r.length):e})).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){const t=te(e,"#");this.options.pedantic?e=t.trim():t&&!this.rules.other.endingSpaceChar.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:te(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=te(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(this.rules.other.nonSpaceChar),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=this.rules.other.nextBulletRegex(p),n=this.rules.other.hrRegex(p),r=this.rules.other.fencesBeginRegex(p),i=this.rules.other.headingBeginRegex(p),l=this.rules.other.htmlBeginRegex(p);for(;e;){const u=e.split("\n",1)[0];let g;if(c=u,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),g=c):g=c.replace(this.rules.other.tabCharGlobal," "),r.test(c))break;if(i.test(c))break;if(l.test(c))break;if(t.test(c))break;if(n.test(c))break;if(g.search(this.rules.other.nonSpaceChar)>=p||!c.trim())o+="\n"+g.slice(p);else{if(h)break;if(a.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=u+"\n",e=e.substring(u.length+1),a=g.slice(p)}}r.loose||(l?r.loose=!0:this.rules.other.doubleBlankLine.test(s)&&(l=!0));let u,g=null;this.options.gfm&&(g=this.rules.other.listIsTask.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:s,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}const o=r.items.at(-1);if(!o)return;o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>this.rules.other.anyLine.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;const t=te(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),ne(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return ne(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(e),s=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&s&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}}class re{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new se,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:i,block:U.normal,inline:J.normal};this.options.pedantic?(n.block=U.pedantic,n.inline=J.pedantic):this.options.gfm&&(n.block=U.gfm,this.options.breaks?n.inline=J.breaks:n.inline=J.gfm),this.tokenizer.rules=n}static get rules(){return{block:U,inline:J}}static lex(e,t){return new re(t).lex(e)}static lexInline(e,t){return new re(t).inlineTokens(e)}lex(e){e=e.replace(i.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);const n=t.at(-1);1===s.raw.length&&void 0!==n?n.raw+="\n":t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.at(-1).src=n.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let r=e;if(this.options.extensions?.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(r))){const i=t.at(-1);n&&"paragraph"===i?.type?(i.raw+="\n"+s.raw,i.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(s),n=r.length!==e.length,e=e.substring(s.raw.length)}else if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(s=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(s=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(s=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r=!1,i="";for(;e;){let s;if(r||(i=""),r=!1,this.options.extensions?.inline?.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.escape(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.tag(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.link(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===s.type&&"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s);continue}if(s=this.tokenizer.emStrong(e,n,i)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.codespan(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.br(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.del(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.autolink(e)){e=e.substring(s.raw.length),t.push(s);continue}if(!this.state.inLink&&(s=this.tokenizer.url(e))){e=e.substring(s.raw.length),t.push(s);continue}let l=e;if(this.options.extensions?.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(l=e.substring(0,t+1))}if(s=this.tokenizer.inlineText(l)){e=e.substring(s.raw.length),"_"!==s.raw.slice(-1)&&(i=s.raw.slice(-1)),r=!0;const n=t.at(-1);"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}}class ie{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(i.notSpaceStart)?.[0],r=e.replace(i.endingNewline,"")+"\n";return s?''+(n?r:W(r,!0))+" \n":""+(n?r:W(r,!0))+" \n"}blockquote({tokens:e}){return`\n${this.parser.parse(e)} \n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} \n`}hr(e){return" \n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t\n"+s+""+r+">\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+W(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`${t} \n`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`${this.parser.parseInline(e)}
\n`}table(e){let t="",n="";for(let t=0;t${s}`),"\n"}tablerow({text:e}){return`\n${e} \n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`${n}>\n`}strong({tokens:e}){return`${this.parser.parseInline(e)} `}em({tokens:e}){return`${this.parser.parseInline(e)} `}codespan({text:e}){return`${W(e,!0)}`}br(e){return" "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=Y(e);if(null===r)return s;let i='"+s+" ",i}image({href:e,title:t,text:n}){const s=Y(e);if(null===s)return W(n);let r=` ",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:W(e.text)}}class le{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}}class oe{options;renderer;textRenderer;constructor(t){this.options=t||e.defaults,this.options.renderer=this.options.renderer||new ie,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new le}static parse(e,t){return new oe(t).parse(e)}static parseInline(e,t){return new oe(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new ie(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new se(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new ae;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;const s=n,r=e.hooks[s],i=t[s];ae.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return re.lex(e,t??this.defaults)}parser(e,t){return oe.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===s.async)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==t)return i(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const l=r.hooks?r.hooks.provideLexer():e?re.lex:re.lexInline,o=r.hooks?r.hooks.provideParser():e?oe.parse:oe.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then((e=>l(e,r))).then((e=>r.hooks?r.hooks.processAllTokens(e):e)).then((e=>r.walkTokens?Promise.all(this.walkTokens(e,r.walkTokens)).then((()=>e)):e)).then((e=>o(e,r))).then((e=>r.hooks?r.hooks.postprocess(e):e)).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let e=l(t,r);r.hooks&&(e=r.hooks.processAllTokens(e)),r.walkTokens&&this.walkTokens(e,r.walkTokens);let n=o(e,r);return r.hooks&&(n=r.hooks.postprocess(n)),n}catch(e){return i(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="An error occurred:
"+W(n.message+"",!0)+" ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const he=new ce;function pe(e,t){return he.parse(e,t)}pe.options=pe.setOptions=function(e){return he.setOptions(e),pe.defaults=he.defaults,n(pe.defaults),pe},pe.getDefaults=t,pe.defaults=e.defaults,pe.use=function(...e){return he.use(...e),pe.defaults=he.defaults,n(pe.defaults),pe},pe.walkTokens=function(e,t){return he.walkTokens(e,t)},pe.parseInline=he.parseInline,pe.Parser=oe,pe.parser=oe.parse,pe.Renderer=ie,pe.TextRenderer=le,pe.Lexer=re,pe.lexer=re.lex,pe.Tokenizer=se,pe.Hooks=ae,pe.parse=pe;const ue=pe.options,ge=pe.setOptions,ke=pe.use,de=pe.walkTokens,fe=pe.parseInline,xe=pe,be=oe.parse,we=re.lex;e.Hooks=ae,e.Lexer=re,e.Marked=ce,e.Parser=oe,e.Renderer=ie,e.TextRenderer=le,e.Tokenizer=se,e.getDefaults=t,e.lexer=we,e.marked=pe,e.options=ue,e.parse=xe,e.parseInline=fe,e.parser=be,e.setOptions=ge,e.use=ke,e.walkTokens=de}));
diff --git a/frontends/desktop_bridge.py b/frontends/desktop_bridge.py
new file mode 100644
index 000000000..6bd96bf2a
--- /dev/null
+++ b/frontends/desktop_bridge.py
@@ -0,0 +1,613 @@
+#!/usr/bin/env python3
+"""
+GenericAgent Web2 Bridge.
+
+Clear split:
+1) AgentManager: owns GenericAgent instances, sessions and histories.
+2) Transport: HTTP is the command/data channel; WebSocket only pushes small
+ session-state notifications.
+
+HTTP API:
+ GET /status
+ GET /config
+ POST /config
+ GET /model-profiles
+ GET /sessions
+ POST /session/new
+ GET /session/{sid}
+ DELETE /session/{sid}
+ POST /session/{sid}/prompt
+ GET /session/{sid}/messages?after=0&limit=200
+ POST /session/{sid}/cancel
+
+WS API:
+ GET /ws -> events only, e.g.
+ {"type":"session-state","sessionId":"sess-...","state":"running","seq":3,"updatedAt":...}
+"""
+from __future__ import annotations
+
+import asyncio, contextlib, importlib, json, os, sys
+import threading, time, traceback, uuid
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Set
+from aiohttp import web, WSMsgType
+
+APP_DIR = Path(__file__).resolve().parent
+
+
+def find_default_ga_root() -> Path:
+ candidates = [
+ APP_DIR / "..",
+ APP_DIR / ".." / "..",
+ APP_DIR / ".." / "GenericAgent",
+ APP_DIR / ".." / ".." / "GenericAgent",
+ ]
+ for p in candidates:
+ root = p.resolve()
+ if (root / "agentmain.py").exists():
+ return root
+ return APP_DIR.parent.parent.resolve()
+
+
+DEFAULT_GA_ROOT = find_default_ga_root()
+
+for _s in (sys.stdout, sys.stderr):
+ with contextlib.suppress(Exception):
+ _s.reconfigure(encoding="utf-8", errors="replace")
+
+
+# ---------------------------------------------------------------------------
+# Agent management layer
+# ---------------------------------------------------------------------------
+
+@dataclass
+class Session:
+ id: str
+ title: str = "New chat"
+ cwd: str = ""
+ created_at: float = field(default_factory=time.time)
+ updated_at: float = field(default_factory=time.time)
+ messages: List[dict] = field(default_factory=list)
+ msg_seq: int = 0
+ partial: Optional[dict] = None
+ status: str = "idle" # idle|running|error|cancelled
+ agent: Any = None
+ thread: Optional[threading.Thread] = None
+ cancel_requested: bool = False
+ last_error: str = ""
+
+
+class AgentManager:
+ def __init__(self):
+ self.lock = threading.RLock()
+ self.ga_root = str(DEFAULT_GA_ROOT)
+ self.config: Dict[str, Any] = {}
+ self.sessions: Dict[str, Session] = {}
+ self.active_session_id: Optional[str] = None
+
+ @property
+ def mykey_path(self) -> str:
+ return str(Path(self.ga_root) / "mykey.py")
+
+ def ensure_ga_import_path(self) -> Path:
+ root = Path(self.ga_root).resolve()
+ if str(root) not in sys.path:
+ sys.path.insert(0, str(root))
+ return root
+
+ def make_agent(self, sess: Session):
+ root = self.ensure_ga_import_path()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(sess.cwd or str(root))
+ agentmain = importlib.import_module("agentmain")
+ GA = getattr(agentmain, "GenericAgent")
+ agent = GA()
+ agent.inc_out = True
+ agent.verbose = True
+ threading.Thread(target=agent.run, daemon=True, name=f"GA-{sess.id}").start()
+ return agent
+ finally:
+ with contextlib.suppress(Exception):
+ os.chdir(old_cwd)
+
+ def list_model_profiles(self):
+ self.ensure_ga_import_path()
+ try:
+ agentmain = importlib.import_module("agentmain")
+ agent = agentmain.GenericAgent()
+ if hasattr(agent, "list_llms"):
+ return [{"id": i, "name": name, "active": active} for i, name, active in agent.list_llms()]
+ except Exception as e:
+ print(f"get model profiles failed: {e}", file=sys.stderr)
+ return []
+
+ def snapshot(self, sess: Session, include_messages: bool = True) -> dict:
+ out = {
+ "sessionId": sess.id,
+ "id": sess.id,
+ "title": sess.title,
+ "cwd": sess.cwd,
+ "status": sess.status,
+ "createdAt": sess.created_at,
+ "updatedAt": sess.updated_at,
+ "lastError": sess.last_error,
+ "msgSeq": sess.msg_seq,
+ }
+ if include_messages:
+ out["messages"] = list(sess.messages)
+ out["partial"] = dict(sess.partial) if sess.partial else None
+ return out
+
+ def add_message(self, sess: Session, role: str, content: str, **extra) -> dict:
+ sess.msg_seq += 1
+ msg = {"id": sess.msg_seq, "role": role, "content": content, "ts": time.time()}
+ msg.update(extra)
+ sess.messages.append(msg)
+ sess.updated_at = time.time()
+ if role == "user" and content.strip() and sess.title == "New chat":
+ sess.title = content.strip().replace("\n", " ")[:40]
+ return msg
+
+ def create_session(self, cwd: Optional[str] = None) -> Session:
+ sid = "sess-" + uuid.uuid4().hex[:12]
+ sess = Session(id=sid, cwd=str(cwd or self.ga_root))
+ with self.lock:
+ self.sessions[sid] = sess
+ self.active_session_id = sid
+ emit_session_state(sess, "created")
+ return sess
+
+ def get_session(self, sid: str) -> Session:
+ with self.lock:
+ sess = self.sessions.get(sid)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ return sess
+
+ def delete_session(self, sid: str) -> dict:
+ with self.lock:
+ sess = self.sessions.pop(sid, None)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ if self.active_session_id == sid:
+ self.active_session_id = next(iter(self.sessions), None)
+ if sess.agent and hasattr(sess.agent, "abort"):
+ with contextlib.suppress(Exception):
+ sess.agent.abort()
+ emit_session_state(sess, "closed")
+ return {"ok": True, "sessionId": sid}
+
+ def submit_prompt(self, sid: str, prompt: Any, images: Optional[list] = None) -> dict:
+ prompt, image_ids = normalize_prompt(prompt, images)
+ with self.lock:
+ sess = self.sessions.get(sid)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ if sess.status == "running":
+ raise web.HTTPConflict(text=json.dumps({"error": "session is already running"}, ensure_ascii=False), content_type="application/json")
+ extra = {}
+ if image_ids:
+ extra["image_ids"] = image_ids
+ user_msg = self.add_message(sess, "user", prompt, **extra)
+ sess.status = "running"
+ sess.cancel_requested = False
+ sess.last_error = ""
+ sess.partial = {"id": sess.msg_seq + 1, "role": "assistant", "content": "", "ts": time.time(), "partial": True}
+ t = threading.Thread(target=self.run_agent_turn, args=(sess, prompt, None), daemon=True, name=f"Turn-{sid}")
+ sess.thread = t
+ t.start()
+ seq = sess.msg_seq
+ emit_session_state(sess, "running")
+ return {"ok": True, "sessionId": sid, "accepted": True, "userMessageId": user_msg["id"], "seq": seq}
+
+ def run_agent_turn(self, sess: Session, prompt: str, images: Optional[list] = None):
+ try:
+ if sess.agent is None:
+ sess.agent = self.make_agent(sess)
+ agent = sess.agent
+ full = ""
+ if hasattr(agent, "put_task"):
+ display_q = agent.put_task(prompt, images=images or [])
+ pieces = []
+ import queue as _queue
+ while True:
+ if sess.cancel_requested:
+ break
+ try:
+ item = display_q.get(timeout=1.0)
+ except _queue.Empty:
+ continue
+ if isinstance(item, dict):
+ if item.get("next"):
+ text = str(item["next"])
+ pieces.append(text)
+ with self.lock:
+ if sess.partial is not None:
+ sess.partial["content"] = "".join(pieces) if getattr(agent, "inc_out", False) else text
+ sess.partial["ts"] = time.time()
+ sess.updated_at = time.time()
+ if "done" in item:
+ full = str(item.get("done") or "")
+ break
+ else:
+ pieces.append(str(item))
+ if not full and pieces:
+ full = pieces[-1] if not getattr(agent, "inc_out", False) else "".join(pieces)
+ elif hasattr(agent, "run"):
+ ret = agent.run(prompt)
+ if isinstance(ret, str):
+ full = ret
+ else:
+ full = "GenericAgent object has no put_task/run method"
+ if not full:
+ full = "(completed)"
+ if sess.cancel_requested:
+ with self.lock:
+ sess.partial = None
+ # Ensure status stays cancelled (don't overwrite)
+ if sess.status != "cancelled":
+ sess.status = "cancelled"
+ sess.updated_at = time.time()
+ emit_session_state(sess, "cancelled")
+ return
+ with self.lock:
+ sess.partial = None
+ # Strip trailing [Info] Final response to user. marker
+ import re as _re
+ full = _re.sub(r'\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$', '', full)
+ self.add_message(sess, "assistant", full)
+ sess.status = "idle"
+ sess.last_error = ""
+ emit_session_state(sess, "idle")
+ except Exception as e:
+ tb = traceback.format_exc()
+ with self.lock:
+ sess.partial = None
+ sess.status = "error"
+ sess.last_error = str(e)
+ self.add_message(sess, "error", str(e))
+ print(tb, file=sys.stderr)
+ emit_session_state(sess, "error")
+
+ def messages(self, sid: str, after: int = 0, limit: int = 200) -> dict:
+ with self.lock:
+ sess = self.sessions.get(sid)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ msgs = [m for m in sess.messages if int(m.get("id", 0)) > after]
+ if limit > 0:
+ msgs = msgs[-limit:]
+ return {
+ "sessionId": sid,
+ "status": sess.status,
+ "messages": msgs,
+ "partial": dict(sess.partial) if sess.partial else None,
+ "msgSeq": sess.msg_seq,
+ "updatedAt": sess.updated_at,
+ "lastError": sess.last_error,
+ }
+
+ def cancel(self, sid: str) -> dict:
+ with self.lock:
+ sess = self.sessions.get(sid)
+ if not sess:
+ raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
+ sess.cancel_requested = True
+ if sess.agent and hasattr(sess.agent, "abort"):
+ with contextlib.suppress(Exception):
+ sess.agent.abort()
+ sess.status = "cancelled"
+ sess.partial = None
+ sess.updated_at = time.time()
+ emit_session_state(sess, "cancelled")
+ return {"ok": True, "sessionId": sid}
+
+
+import base64
+import tempfile
+
+# Shared temp dir for image uploads (persists for process lifetime)
+_UPLOAD_DIR = Path(tempfile.gettempdir()) / "ga_web2_uploads"
+_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def _save_image_data(data_url: str, img_id: str) -> str:
+ """Save a data URL to disk, return absolute path."""
+ # data:image/png;base64,xxxxx
+ if "," in data_url:
+ header, b64 = data_url.split(",", 1)
+ else:
+ b64 = data_url
+ header = ""
+ ext = "png"
+ if "jpeg" in header or "jpg" in header:
+ ext = "jpg"
+ elif "webp" in header:
+ ext = "webp"
+ elif "gif" in header:
+ ext = "gif"
+ fpath = _UPLOAD_DIR / f"{img_id}.{ext}"
+ fpath.write_bytes(base64.b64decode(b64))
+ return str(fpath)
+
+
+def normalize_prompt(prompt: Any, images: Optional[list] = None):
+ """Normalize prompt and images.
+
+ images: list of dicts {"id": "img-xxx", "dataUrl": "data:..."} or plain data URLs.
+ Returns: (prompt_text_with_image_tags, image_ids_list)
+ """
+ images = list(images or [])
+ if isinstance(prompt, list):
+ text_parts = []
+ for part in prompt:
+ if isinstance(part, str):
+ text_parts.append(part)
+ elif isinstance(part, dict):
+ if part.get("type") in ("text", "input_text"):
+ text_parts.append(str(part.get("text") or part.get("content") or ""))
+ elif part.get("type") in ("image", "input_image"):
+ url = part.get("image_url") or part.get("url") or part.get("data")
+ if isinstance(url, dict):
+ url = url.get("url")
+ if url:
+ images.append(url)
+ prompt = "\n".join([p for p in text_parts if p])
+
+ # Process images: save to disk, build [image:path] tags
+ image_ids = []
+ image_tags = []
+ for img in images:
+ if isinstance(img, dict):
+ img_id = img.get("id") or f"img-{uuid.uuid4().hex[:8]}"
+ data_url = img.get("dataUrl") or img.get("data_url") or ""
+ else:
+ # Plain data URL string
+ img_id = f"img-{uuid.uuid4().hex[:8]}"
+ data_url = str(img)
+ if data_url:
+ path = _save_image_data(data_url, img_id)
+ image_tags.append(f"[image:{path}]")
+ image_ids.append(img_id)
+
+ # Append image tags to prompt
+ final_prompt = str(prompt or "")
+ if image_tags:
+ final_prompt = final_prompt + "\n" + "\n".join(image_tags)
+
+ return final_prompt, image_ids
+
+
+manager = AgentManager()
+
+
+# ---------------------------------------------------------------------------
+# Transport layer: WS notification only
+# ---------------------------------------------------------------------------
+
+class WsHub:
+ def __init__(self):
+ self.websockets: Set[web.WebSocketResponse] = set()
+ self.loop: Optional[asyncio.AbstractEventLoop] = None
+
+ def emit(self, obj: dict):
+ if self.loop and self.loop.is_running():
+ asyncio.run_coroutine_threadsafe(self._broadcast(obj), self.loop)
+
+ async def _broadcast(self, obj: dict):
+ data = json.dumps(obj, ensure_ascii=False, default=str)
+ dead = set()
+ for ws in list(self.websockets):
+ try:
+ await ws.send_str(data)
+ except Exception:
+ dead.add(ws)
+ self.websockets.difference_update(dead)
+
+
+hub = WsHub()
+
+
+def emit_session_state(sess: Session, state_name: str):
+ hub.emit({
+ "type": "session-state",
+ "sessionId": sess.id,
+ "state": state_name,
+ "status": sess.status,
+ "seq": sess.msg_seq,
+ "updatedAt": sess.updated_at,
+ "title": sess.title,
+ })
+
+
+async def ws_handler(request):
+ ws = web.WebSocketResponse(heartbeat=30)
+ await ws.prepare(request)
+ hub.websockets.add(ws)
+ await ws.send_str(json.dumps({
+ "type": "bridge-ready",
+ "gaRoot": manager.ga_root,
+ "mykeyPath": manager.mykey_path,
+ "http": True,
+ "wsEventsOnly": True,
+ }, ensure_ascii=False))
+ async for msg in ws:
+ if msg.type == WSMsgType.TEXT:
+ # WS is intentionally not a data/command channel anymore.
+ with contextlib.suppress(Exception):
+ data = json.loads(msg.data)
+ if data.get("action") == "ping":
+ await ws.send_str(json.dumps({"type": "pong", "ts": time.time()}, ensure_ascii=False))
+ hub.websockets.discard(ws)
+ return ws
+
+
+# ---------------------------------------------------------------------------
+# Transport layer: HTTP command/data API
+# ---------------------------------------------------------------------------
+
+def cors_headers():
+ return {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ }
+
+
+@web.middleware
+async def cors_middleware(request, handler):
+ if request.method == "OPTIONS":
+ return web.Response(status=204, headers=cors_headers())
+ resp = await handler(request)
+ for k, v in cors_headers().items():
+ resp.headers[k] = v
+ return resp
+
+
+def json_ok(data: dict, status: int = 200):
+ return web.json_response(data, status=status, headers=cors_headers(), dumps=lambda x: json.dumps(x, ensure_ascii=False, default=str))
+
+
+async def read_json(request) -> dict:
+ if request.can_read_body:
+ try:
+ data = await request.json()
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+ return {}
+
+
+async def status_handler(request):
+ return json_ok({
+ "ok": True,
+ "running": True,
+ "ready": True,
+ "gaRoot": manager.ga_root,
+ "mykeyPath": manager.mykey_path,
+ "sessionCount": len(manager.sessions),
+ "activeSessionId": manager.active_session_id,
+ "ws": "/ws",
+ "transport": {"http": True, "wsEventsOnly": True},
+ })
+
+
+async def get_config_handler(request):
+ return json_ok({"gaRoot": manager.ga_root, "mykeyPath": manager.mykey_path, "config": manager.config})
+
+
+async def save_config_handler(request):
+ data = await read_json(request)
+ cfg = data.get("config", data)
+ if isinstance(cfg, dict):
+ manager.config.update(cfg)
+ return json_ok({"ok": True, "gaRoot": manager.ga_root, "mykeyPath": manager.mykey_path, "config": manager.config})
+
+
+async def model_profiles_handler(request):
+ return json_ok({"profiles": manager.list_model_profiles()})
+
+
+async def list_sessions_handler(request):
+ with manager.lock:
+ sessions = [manager.snapshot(s, include_messages=False) for s in manager.sessions.values()]
+ return json_ok({"sessions": sessions, "activeSessionId": manager.active_session_id})
+
+
+async def new_session_handler(request):
+ data = await read_json(request)
+ sess = manager.create_session(cwd=data.get("cwd") or data.get("path"))
+ return json_ok({"ok": True, "sessionId": sess.id, "session": manager.snapshot(sess)}, status=201)
+
+
+async def get_session_handler(request):
+ sid = request.match_info["sid"]
+ sess = manager.get_session(sid)
+ return json_ok({"sessionId": sid, "session": manager.snapshot(sess), "messages": list(sess.messages), "partial": sess.partial})
+
+
+async def delete_session_handler(request):
+ sid = request.match_info["sid"]
+ return json_ok(manager.delete_session(sid))
+
+
+async def prompt_handler(request):
+ sid = request.match_info["sid"]
+ data = await read_json(request)
+ prompt = data.get("prompt", data.get("content", data.get("message", "")))
+ images = data.get("images") or []
+ return json_ok(manager.submit_prompt(sid, prompt, images))
+
+
+async def messages_handler(request):
+ sid = request.match_info["sid"]
+ after = int(request.query.get("after") or request.query.get("afterId") or 0)
+ limit = int(request.query.get("limit") or 200)
+ return json_ok(manager.messages(sid, after=after, limit=limit))
+
+
+async def cancel_handler(request):
+ sid = request.match_info["sid"]
+ return json_ok(manager.cancel(sid))
+
+
+async def path_open_handler(request):
+ data = await read_json(request)
+ kind = data.get("kind", "")
+ if kind == "mykey":
+ target = Path(manager.ga_root) / "mykey.py"
+ else:
+ target = Path(data.get("path") or data.get("target") or manager.ga_root)
+ target = target.resolve()
+ if not target.exists():
+ return json_ok({"ok": False, "error": f"File not found: {target}"})
+ # Actually open the file with the system default editor
+ import subprocess, platform
+ if platform.system() == "Windows":
+ os.startfile(str(target))
+ elif platform.system() == "Darwin":
+ subprocess.Popen(["open", str(target)])
+ else:
+ subprocess.Popen(["xdg-open", str(target)])
+ return json_ok({"ok": True, "path": str(target)})
+
+
+def create_app():
+ app = web.Application(middlewares=[cors_middleware])
+ app.router.add_get("/ws", ws_handler)
+ app.router.add_get("/status", status_handler)
+ app.router.add_get("/config", get_config_handler)
+ app.router.add_post("/config", save_config_handler)
+ app.router.add_get("/model-profiles", model_profiles_handler)
+ app.router.add_get("/sessions", list_sessions_handler)
+ app.router.add_post("/session/new", new_session_handler)
+ app.router.add_get("/session/{sid}", get_session_handler)
+ app.router.add_delete("/session/{sid}", delete_session_handler)
+ app.router.add_post("/session/{sid}/prompt", prompt_handler)
+ app.router.add_get("/session/{sid}/messages", messages_handler)
+ app.router.add_post("/session/{sid}/cancel", cancel_handler)
+ app.router.add_post("/path/open", path_open_handler)
+
+ # Serve static frontend (desktop/static/)
+ static_dir = APP_DIR / "desktop" / "static"
+
+ async def index_handler(request):
+ return web.FileResponse(static_dir / "index.html")
+
+ app.router.add_get("/", index_handler)
+ app.router.add_static("/", static_dir, show_index=False)
+
+ async def on_startup(app):
+ hub.loop = asyncio.get_running_loop()
+
+ app.on_startup.append(on_startup)
+ return app
+
+
+if __name__ == "__main__":
+ host = os.environ.get("BRIDGE_HOST", "127.0.0.1")
+ port = int(os.environ.get("BRIDGE_PORT", "14168"))
+ print(f"GenericAgent Web2 bridge: http://{host}:{port} ws://{host}:{port}/ws", file=sys.stderr)
+ web.run_app(create_app(), host=host, port=port, print=None)
diff --git a/frontends/desktop_pet_v2.pyw b/frontends/desktop_pet_v2.pyw
new file mode 100644
index 000000000..653aef6b5
--- /dev/null
+++ b/frontends/desktop_pet_v2.pyw
@@ -0,0 +1,1090 @@
+"""Desktop Pet with Skin System — Cross-platform with True Transparency"""
+import os, re, sys, json, threading, io
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from urllib.parse import urlparse, parse_qs
+from PIL import Image, ImageDraw, ImageFont, ImageOps
+
+PORT = 41983
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+PROJECT_DIR = os.path.dirname(SCRIPT_DIR)
+SKINS_DIR = os.path.join(SCRIPT_DIR, 'skins')
+
+class SkinLoader:
+ """Load and parse skin configuration"""
+ @staticmethod
+ def load_skin(skin_path):
+ """Load skin.json and return skin config"""
+ config_file = os.path.join(skin_path, 'skin.json')
+ if not os.path.exists(config_file):
+ raise FileNotFoundError(f"skin.json not found in {skin_path}")
+
+ with open(config_file, 'r', encoding='utf-8') as f:
+ config = json.load(f)
+
+ if 'animations' not in config:
+ raise ValueError("skin.json must contain 'animations' field")
+
+ config['path'] = skin_path
+ return config
+
+ @staticmethod
+ def list_skins():
+ """List all available skins"""
+ if not os.path.exists(SKINS_DIR):
+ return []
+
+ skins = []
+ for item in os.listdir(SKINS_DIR):
+ skin_path = os.path.join(SKINS_DIR, item)
+ if os.path.isdir(skin_path):
+ config_file = os.path.join(skin_path, 'skin.json')
+ if os.path.exists(config_file):
+ skins.append(item)
+ return skins
+
+class AnimationLoader:
+ """Load animation frames from sprite sheet"""
+ @staticmethod
+ def load_sprite_frames(skin_path, anim_config):
+ """Load frames from sprite sheet"""
+ file_path = os.path.join(skin_path, anim_config['file'])
+ sprite_config = anim_config['sprite']
+
+ img = Image.open(file_path)
+ frames = []
+
+ frame_width = sprite_config['frameWidth']
+ frame_height = sprite_config['frameHeight']
+ frame_count = sprite_config['frameCount']
+ columns = sprite_config['columns']
+ start_frame = sprite_config.get('startFrame', 0)
+
+ for i in range(frame_count):
+ frame_idx = start_frame + i
+ row = frame_idx // columns
+ col = frame_idx % columns
+
+ x = col * frame_width
+ y = row * frame_height
+
+ frame = img.crop((x, y, x + frame_width, y + frame_height))
+ frames.append(frame)
+
+ return frames
+
+
+def _load_default_font(size):
+ """Load a usable font for bubble text."""
+ font_candidates = [
+ '/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
+ '/System/Library/Fonts/PingFang.ttc',
+ '/System/Library/Fonts/STHeiti Light.ttc',
+ 'C:/Windows/Fonts/msyh.ttc',
+ 'C:/Windows/Fonts/simhei.ttf',
+ 'C:/Windows/Fonts/arial.ttf',
+ '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
+ '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
+ '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',
+ '/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf',
+ '/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc',
+ ]
+ for font_path in font_candidates:
+ if os.path.exists(font_path):
+ try:
+ return ImageFont.truetype(font_path, size=size)
+ except Exception:
+ pass
+ return ImageFont.load_default()
+
+
+def _normalize_bubble_text(text):
+ """Normalize text for fonts that cannot render some symbols."""
+ text = (text or '').strip()
+ lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
+ if lines:
+ turn_match = re.match(r'^\s*🔄?\s*Turn\s+(\d+)\s*$', lines[0], flags=re.IGNORECASE)
+ if turn_match:
+ rest = '\n'.join(line.strip() for line in lines[1:] if line.strip())
+ return f"Turn {turn_match.group(1)}: {rest}" if rest else f"Turn {turn_match.group(1)}:"
+ return text.replace('🔄 Turn', 'Turn').replace('🔄', '').strip()
+
+
+def _wrap_text_for_width(draw, text, font, max_width):
+ """Wrap text to fit inside max_width."""
+ text = _normalize_bubble_text(text)
+ if not text:
+ return ['']
+
+ paragraphs = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
+ lines = []
+
+ for paragraph in paragraphs:
+ if not paragraph:
+ lines.append('')
+ continue
+
+ current = ''
+ for ch in paragraph:
+ candidate = current + ch
+ bbox = draw.textbbox((0, 0), candidate, font=font)
+ width = bbox[2] - bbox[0]
+ if current and width > max_width:
+ lines.append(current)
+ current = ch
+ else:
+ current = candidate
+ if current:
+ lines.append(current)
+
+ return lines or ['']
+
+
+def build_bubble_image(message, max_width=220):
+ """Build a PIL image for the toast bubble using the user asset when available."""
+ message = (message or '').strip()
+ bubble_path = next((p for p in [os.path.join(SCRIPT_DIR, 'chat_bubble.png'),
+ os.path.join(SCRIPT_DIR, 'bubble.png')]
+ if os.path.exists(p)), None)
+
+ if bubble_path:
+ bubble = Image.open(bubble_path).convert('RGBA')
+ else:
+ bubble = Image.new('RGBA', (256, 128), (255, 255, 255, 0))
+ draw = ImageDraw.Draw(bubble)
+ draw.rounded_rectangle((8, 8, 247, 87), radius=12, fill=(255, 255, 255, 255), outline=(0, 0, 0, 255), width=3)
+ draw.polygon([(48, 87), (72, 87), (56, 112)], fill=(255, 255, 255, 255), outline=(0, 0, 0, 255))
+
+ bubble = ImageOps.contain(bubble, (max_width, max(64, int(max_width * bubble.height / bubble.width))), Image.NEAREST)
+
+ # Detect the actual opaque bubble region to position text correctly
+ alpha = bubble.getchannel('A')
+ content_box = alpha.getbbox() # (left, top, right, bottom) of opaque area
+ if content_box:
+ cb_left, cb_top, cb_right, cb_bottom = content_box
+ else:
+ cb_left, cb_top, cb_right, cb_bottom = 0, 0, bubble.width, bubble.height
+ content_w = cb_right - cb_left
+ content_h = cb_bottom - cb_top
+
+ font_size = max(12, content_h // 6)
+ font = _load_default_font(font_size)
+ draw = ImageDraw.Draw(bubble)
+
+ # Padding relative to the opaque bubble region, not the full image
+ inner_pad_x = max(6, content_w // 14)
+ inner_pad_top = max(4, content_h // 12)
+ inner_pad_bottom = max(12, content_h // 4)
+ text_area_width = max(36, content_w - inner_pad_x * 2)
+
+ lines = _wrap_text_for_width(draw, message, font, text_area_width)
+ ascent, descent = font.getmetrics() if hasattr(font, 'getmetrics') else (font_size, font_size // 4)
+ line_height = max(font_size, ascent + descent)
+ usable_h = content_h - inner_pad_top - inner_pad_bottom
+ max_lines = max(1, usable_h // line_height)
+ if len(lines) > max_lines:
+ lines = lines[:max_lines]
+ if lines:
+ last = lines[-1]
+ while last and draw.textbbox((0, 0), last + '…', font=font)[2] > text_area_width:
+ last = last[:-1]
+ lines[-1] = (last + '…') if last else '…'
+
+ total_text_height = len(lines) * line_height
+ y = cb_top + inner_pad_top + max(0, (usable_h - total_text_height) // 2) - 3
+
+ for line in lines:
+ bbox = draw.textbbox((0, 0), line, font=font)
+ text_width = bbox[2] - bbox[0]
+ x = cb_left + inner_pad_x + (text_area_width - text_width) / 2
+ draw.text((x, y), line, font=font, fill=(32, 32, 32, 255))
+ y += line_height
+
+ alpha = bubble.getchannel('A')
+ bbox = alpha.getbbox()
+ if bbox:
+ bubble = bubble.crop(bbox)
+
+ width, height = bubble.size
+ alpha = bubble.getchannel('A')
+ bottom_y = height - 1
+ tail_x = width // 2
+ for y in range(height - 1, -1, -1):
+ xs = [x for x in range(width) if alpha.getpixel((x, y)) > 0]
+ if xs:
+ bottom_y = y
+ tail_x = xs[len(xs) // 2]
+ break
+
+ return {
+ 'image': bubble,
+ 'size': bubble.size,
+ 'tail_tip': (tail_x, bottom_y),
+ }
+
+# ============================================================================
+# Shared Base Class
+# ============================================================================
+class PetBase:
+ """Shared logic for Mac and Windows pet implementations."""
+
+ def _schedule_main(self, fn):
+ """Schedule fn on the GUI main thread. Subclasses must override."""
+ raise NotImplementedError
+
+ def set_state_safe(self, state):
+ """Thread-safe wrapper for set_state."""
+ self._schedule_main(lambda: self.set_state(state))
+
+ def show_toast_safe(self, message):
+ """Thread-safe wrapper for show_toast."""
+ self._schedule_main(lambda m=message: self.show_toast(m))
+
+ def _start_server(self):
+ """Start HTTP control server."""
+ pet = self
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ parsed = urlparse(self.path)
+ params = parse_qs(parsed.query)
+
+ if 'state' in params:
+ state = params['state'][0]
+ pet.set_state_safe(state)
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(b'ok')
+ elif 'msg' in params:
+ msg = params['msg'][0]
+ pet.show_toast_safe(msg)
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(b'ok')
+ else:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b'?state=idle/walk/run/sprint or ?msg=hello')
+
+ def do_POST(self):
+ body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode()
+ if body:
+ pet.show_toast_safe(body)
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(b'ok')
+ else:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b'empty body')
+
+ def log_message(self, *a):
+ pass
+
+ try:
+ HTTPServer.allow_reuse_address = True
+ srv = HTTPServer(('127.0.0.1', PORT), Handler)
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ print(f'✓ Server: http://127.0.0.1:{PORT}/?state=walk')
+ except OSError as e:
+ if e.errno == 48:
+ print(f'⚠ Port {PORT} already in use')
+ else:
+ raise
+
+
+# ============================================================================
+# macOS Implementation - Pure Cocoa with True Transparency
+# ============================================================================
+if sys.platform == 'darwin':
+ from Cocoa import (
+ NSApplication, NSWindow, NSImageView, NSImage, NSData, NSTimer,
+ NSMenu, NSMenuItem, NSApp, NSFloatingWindowLevel, NSColor,
+ NSBackingStoreBuffered, NSWindowStyleMaskBorderless,
+ NSApplicationActivationPolicyAccessory
+ )
+ from Foundation import NSMakeRect, NSMakePoint, NSMakeSize
+ from PyObjCTools import AppHelper
+ import objc
+
+ class MacPet(PetBase):
+ def __init__(self, skin_name=None):
+ self.app = NSApplication.sharedApplication()
+ self.app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
+
+ # Load skin
+ self.load_skin(skin_name)
+ self.available_skins = SkinLoader.list_skins()
+
+ # Get screen size
+ from AppKit import NSScreen, NSWindowCollectionBehaviorCanJoinAllSpaces, NSWindowCollectionBehaviorStationary
+ screen = NSScreen.mainScreen()
+ screen_frame = screen.frame()
+ screen_width = screen_frame.size.width
+ screen_height = screen_frame.size.height
+
+ # Position at right side
+ x_pos = screen_width - 200
+ y_pos = 300
+
+ # Create transparent window
+ self.window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
+ NSMakeRect(x_pos, y_pos, self.display_width, self.display_height),
+ NSWindowStyleMaskBorderless,
+ NSBackingStoreBuffered,
+ False
+ )
+
+ self.window.setOpaque_(False)
+ self.window.setBackgroundColor_(NSColor.clearColor())
+ self.window.setLevel_(NSFloatingWindowLevel)
+ self.window.setMovableByWindowBackground_(True)
+ self.window.setAcceptsMouseMovedEvents_(True)
+
+ # Make window sticky across spaces (stays in fixed screen position)
+ self.window.setCollectionBehavior_(
+ NSWindowCollectionBehaviorCanJoinAllSpaces |
+ NSWindowCollectionBehaviorStationary
+ )
+
+ # Create custom view for handling mouse events
+ from AppKit import NSView
+ from objc import super as objc_super
+
+ class DraggableImageView(NSView):
+ """Custom view that handles dragging and double-click"""
+ def initWithFrame_(self, frame):
+ self = objc_super(DraggableImageView, self).initWithFrame_(frame)
+ if self is None:
+ return None
+ self.image_view = NSImageView.alloc().initWithFrame_(self.bounds())
+ self.image_view.setImageScaling_(1) # NSImageScaleProportionallyUpOrDown
+ self.addSubview_(self.image_view)
+
+ # Create overlay view for toast (always on top)
+ # Make it non-opaque so it doesn't block the image
+ self.overlay_view = NSView.alloc().initWithFrame_(self.bounds())
+ self.overlay_view.setWantsLayer_(True)
+ self.addSubview_(self.overlay_view)
+
+ self.drag_start = None
+ return self
+
+ def mouseDown_(self, event):
+ """Handle mouse down for dragging"""
+ if event.clickCount() == 2:
+ # Double-click to quit
+ from AppKit import NSApp
+ NSApp.terminate_(None)
+ else:
+ # Start dragging
+ self.drag_start = event.locationInWindow()
+
+ def mouseDragged_(self, event):
+ """Handle mouse drag"""
+ if self.drag_start:
+ current_location = event.locationInWindow()
+ window_frame = self.window().frame()
+
+ dx = current_location.x - self.drag_start.x
+ dy = current_location.y - self.drag_start.y
+
+ new_origin = NSMakePoint(
+ window_frame.origin.x + dx,
+ window_frame.origin.y + dy
+ )
+
+ self.window().setFrameOrigin_(new_origin)
+
+ def acceptsFirstMouse_(self, event):
+ """Accept first mouse click"""
+ return True
+
+ def rightMouseDown_(self, event):
+ from AppKit import NSMenu, NSMenuItem, NSApp
+
+ menu = NSMenu.alloc().init()
+ pet = getattr(self, 'mac_pet', None) or self.window().delegate()
+ if not pet:
+ return
+
+ for skin_name in pet.available_skins: # preload this in MacPet.__init__
+ item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+ skin_name,
+ 'changeSkin:',
+ ''
+ )
+ item.setTarget_(pet)
+ item.setRepresentedObject_(skin_name)
+ menu.addItem_(item)
+
+ menu.addItem_(NSMenuItem.separatorItem())
+ quit_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_('Quit', 'terminate:', '')
+ menu.addItem_(quit_item)
+
+ NSApp.activateIgnoringOtherApps_(True)
+ NSMenu.popUpContextMenu_withEvent_forView_(menu, event, self)
+
+ # Create draggable view
+ self.content_view = DraggableImageView.alloc().initWithFrame_(
+ NSMakeRect(0, 0, self.display_width, self.display_height)
+ )
+ self.content_view.mac_pet = self
+ self.image_view = self.content_view.image_view
+ self.overlay_view = self.content_view.overlay_view
+ self.window.setContentView_(self.content_view)
+
+ # Animation state
+ self.current_state = 'idle'
+ self.frame_idx = 0
+
+ # Toast state
+ self.toast_label = None
+ self.toast_timer = None
+ self.toast_image = None
+ self.toast_window = None
+
+ # Start animation timer
+ self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
+ 1.0 / self.animations[self.current_state]['fps'],
+ self,
+ 'animate:',
+ None,
+ True
+ )
+
+ # Show window
+ self.window.makeKeyAndOrderFront_(None)
+
+ # Start HTTP server
+ self._start_server()
+
+ print(f"✓ macOS Pet started at ({x_pos}, {y_pos})")
+ print(f" Animations: {', '.join(self.animations.keys())}")
+
+ def load_skin(self, skin_name=None):
+ """Load skin configuration and animations"""
+ available_skins = SkinLoader.list_skins()
+ if not available_skins:
+ raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
+
+ if skin_name is None or skin_name not in available_skins:
+ skin_name = available_skins[0]
+
+ skin_path = os.path.join(SKINS_DIR, skin_name)
+ self.skin_config = SkinLoader.load_skin(skin_path)
+
+ # Get display size
+ display_size = self.skin_config.get('size', {})
+ self.display_width = display_size.get('width', 128)
+ self.display_height = display_size.get('height', 128)
+
+ # Load animations
+ self.animations = {}
+ for anim_name, anim_config in self.skin_config['animations'].items():
+ pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
+
+ # Scale frames
+ scaled_frames = []
+ for frame in pil_frames:
+ if frame.mode != 'RGBA':
+ frame = frame.convert('RGBA')
+ scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
+ scaled_frames.append(scaled)
+
+ # Convert to NSImage with proper alpha handling
+ ns_images = []
+ for pil_img in scaled_frames:
+ # Convert PIL to PNG bytes (PNG preserves alpha channel)
+ png_buffer = io.BytesIO()
+ pil_img.save(png_buffer, format='PNG')
+ png_data = png_buffer.getvalue()
+
+ # Create NSImage from PNG data
+ ns_data = NSData.dataWithBytes_length_(png_data, len(png_data))
+ ns_image = NSImage.alloc().initWithData_(ns_data)
+ ns_images.append(ns_image)
+
+ self.animations[anim_name] = {
+ 'frames': ns_images,
+ 'fps': anim_config.get('sprite', {}).get('fps', 6)
+ }
+
+ def animate_(self, timer):
+ """Animation callback"""
+ anim = self.animations[self.current_state]
+ frames = anim['frames']
+
+ if frames:
+ self.image_view.setImage_(frames[self.frame_idx])
+ self.frame_idx = (self.frame_idx + 1) % len(frames)
+
+ def set_state(self, state):
+ """Change animation state (must be called on main thread)"""
+ if state in self.animations and state != self.current_state:
+ self.current_state = state
+ self.frame_idx = 0
+
+ # Update timer interval
+ self.timer.invalidate()
+ self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
+ 1.0 / self.animations[self.current_state]['fps'],
+ self,
+ 'animate:',
+ None,
+ True
+ )
+ print(f"→ State: {state}")
+
+ def _schedule_main(self, fn):
+ AppHelper.callAfter(fn)
+
+ def show_toast(self, message):
+ """Show toast message above pet"""
+ from AppKit import NSImageView
+
+ if self.toast_window:
+ self.toast_window.orderOut_(None)
+ self.toast_window = None
+ self.toast_label = None
+ if self.toast_timer:
+ self.toast_timer.invalidate()
+ self.toast_timer = None
+
+ bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
+ bubble_pil = bubble_info['image']
+ bubble_width, bubble_height = bubble_info['size']
+ tail_x, tail_y = bubble_info['tail_tip']
+
+ png_buffer = io.BytesIO()
+ bubble_pil.save(png_buffer, format='PNG')
+ png_data = png_buffer.getvalue()
+ ns_data = NSData.dataWithBytes_length_(png_data, len(png_data))
+ self.toast_image = NSImage.alloc().initWithData_(ns_data)
+
+ pet_frame = self.window.frame()
+ anchor_x = pet_frame.origin.x + self.display_width * 0.75
+ anchor_y = pet_frame.origin.y + self.display_height * 1.65
+ toast_x = anchor_x - tail_x
+ toast_y = anchor_y - tail_y
+
+ self.toast_window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
+ NSMakeRect(toast_x, toast_y, bubble_width, bubble_height),
+ NSWindowStyleMaskBorderless,
+ NSBackingStoreBuffered,
+ False
+ )
+ self.toast_window.setOpaque_(False)
+ self.toast_window.setBackgroundColor_(NSColor.clearColor())
+ self.toast_window.setLevel_(NSFloatingWindowLevel)
+ self.toast_window.setIgnoresMouseEvents_(True)
+ self.toast_window.setHasShadow_(False)
+
+ self.toast_label = NSImageView.alloc().initWithFrame_(
+ NSMakeRect(0, 0, bubble_width, bubble_height)
+ )
+ self.toast_label.setImage_(self.toast_image)
+ self.toast_label.setImageScaling_(0)
+ self.toast_window.setContentView_(self.toast_label)
+ self.toast_window.orderFrontRegardless()
+
+ self.toast_timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
+ 3.0,
+ self,
+ 'hideToast:',
+ None,
+ False
+ )
+ print(f"Toast: {message}")
+
+ def hideToast_(self, timer):
+ """Hide toast message"""
+ if self.toast_window:
+ self.toast_window.orderOut_(None)
+ self.toast_window = None
+ self.toast_label = None
+ self.toast_image = None
+ self.toast_timer = None
+
+ def run(self):
+ """Run the application"""
+ AppHelper.runEventLoop()
+
+ def changeSkin_(self, sender):
+ skin_name = sender.representedObject()
+ print(f"Changing skin to: {skin_name}")
+ self.load_skin(skin_name)
+ self.current_state = 'idle'
+ self.frame_idx = 0
+
+# ============================================================================
+# Windows/Linux Implementations
+# ============================================================================
+else:
+ if sys.platform.startswith('win'):
+ import tkinter as tk
+ from PIL import ImageTk
+
+ class WinPet(PetBase):
+ def __init__(self, skin_name=None):
+ self.root = tk.Tk()
+ self.root.wm_attributes('-topmost', True)
+ self.is_windows = sys.platform.startswith('win')
+ self.platform_name = 'Windows' if self.is_windows else 'Linux'
+ self.pet_bg_color = '#F0F0F0' if self.is_windows else 'black'
+ self.toast_bg_color = '#00ff01' if self.is_windows else 'black'
+
+ # Load skin
+ self.load_skin(skin_name)
+
+ # Setup window
+ screen_width = self.root.winfo_screenwidth()
+ screen_height = self.root.winfo_screenheight()
+
+ x_pos = screen_width - 200
+ y_pos = screen_height - 300
+
+ self.root.geometry(f'{self.display_width}x{self.display_height}+{x_pos}+{y_pos}')
+ self.root.overrideredirect(True)
+ self.root.wm_attributes('-topmost', True)
+
+ # Transparent background
+ if self.is_windows:
+ self.root.wm_attributes('-transparentcolor', self.pet_bg_color)
+ self.root.config(bg=self.pet_bg_color)
+
+ # Create label
+ self.label = tk.Label(self.root, bg=self.pet_bg_color, bd=0)
+ self.label.pack()
+
+ # Bind events
+ self.label.bind('', lambda e: setattr(self, '_d', (e.x, e.y)))
+ self.label.bind('', self._drag)
+ self.label.bind('', lambda e: (self.root.destroy(), os._exit(0)))
+ self.label.bind('', self._on_right_click)
+
+ # Animation state
+ self.current_state = 'idle'
+ self.frame_idx = 0
+
+ # Toast state
+ self.toast_window = None
+ self.toast_photo = None
+
+ # Start animation
+ self._animate()
+ self._start_server()
+
+ print(f"✓ {self.platform_name} Pet started at ({x_pos}, {y_pos})")
+ print(f" Animations: {', '.join(self.animations.keys())}")
+
+ self.root.mainloop()
+
+ def load_skin(self, skin_name=None):
+ """Load skin configuration and animations"""
+ available_skins = SkinLoader.list_skins()
+ if not available_skins:
+ raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
+
+ if skin_name is None or skin_name not in available_skins:
+ skin_name = available_skins[0]
+
+ skin_path = os.path.join(SKINS_DIR, skin_name)
+ self.skin_config = SkinLoader.load_skin(skin_path)
+
+ # Get display size
+ display_size = self.skin_config.get('size', {})
+ self.display_width = display_size.get('width', 128)
+ self.display_height = display_size.get('height', 128)
+
+ # Load animations
+ self.animations = {}
+ for anim_name, anim_config in self.skin_config['animations'].items():
+ pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
+
+ # Scale and convert frames
+ tk_frames = []
+ for frame in pil_frames:
+ if frame.mode != 'RGBA':
+ frame = frame.convert('RGBA')
+ scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
+ tk_frames.append(ImageTk.PhotoImage(scaled))
+
+ self.animations[anim_name] = {
+ 'frames': tk_frames,
+ 'fps': anim_config.get('sprite', {}).get('fps', 6)
+ }
+
+ def set_state(self, state):
+ """Change animation state"""
+ if state in self.animations and state != self.current_state:
+ self.current_state = state
+ self.frame_idx = 0
+ print(f"→ State: {state}")
+
+ def _drag(self, e):
+ x = self.root.winfo_x() + e.x - self._d[0]
+ y = self.root.winfo_y() + e.y - self._d[1]
+ self.root.geometry(f'+{x}+{y}')
+
+ def _animate(self):
+ """Animate current state"""
+ if self.current_state not in self.animations:
+ self.root.after(100, self._animate)
+ return
+
+ anim = self.animations[self.current_state]
+ frames = anim['frames']
+
+ if frames:
+ self.label.config(image=frames[self.frame_idx])
+ self.frame_idx = (self.frame_idx + 1) % len(frames)
+
+ delay = int(1000 / anim['fps'])
+ self.root.after(delay, self._animate)
+
+ def show_toast(self, message):
+ """Show toast message above pet"""
+ if self.toast_window:
+ try:
+ self.toast_window.destroy()
+ except:
+ pass
+ self.toast_window = None
+
+ bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
+ bubble_pil = bubble_info['image']
+ bubble_width, bubble_height = bubble_info['size']
+ tail_x, tail_y = bubble_info['tail_tip']
+
+ self.toast_photo = ImageTk.PhotoImage(bubble_pil)
+
+ self.toast_window = tk.Toplevel(self.root)
+ self.toast_window.overrideredirect(True)
+ self.toast_window.wm_attributes('-topmost', True)
+ if self.is_windows:
+ self.toast_window.wm_attributes('-transparentcolor', self.toast_bg_color)
+ self.toast_window.config(bg=self.toast_bg_color)
+
+ toast_label = tk.Label(
+ self.toast_window,
+ image=self.toast_photo,
+ bg=self.toast_bg_color,
+ bd=0,
+ highlightthickness=0
+ )
+ toast_label.pack()
+
+ pet_x = self.root.winfo_x()
+ pet_y = self.root.winfo_y()
+ anchor_x = pet_x + int(self.display_width * 0.75)
+ anchor_y = pet_y
+ toast_x = anchor_x - tail_x
+ toast_y = anchor_y - bubble_height
+
+ self.toast_window.geometry(f'{bubble_width}x{bubble_height}+{toast_x}+{toast_y}')
+
+ self.root.after(3000, self._hide_toast)
+ print(f"Toast: {message}")
+
+ def _hide_toast(self):
+ """Hide toast message"""
+ if self.toast_window:
+ try:
+ self.toast_window.destroy()
+ self.toast_window = None
+ except:
+ pass
+
+ def _schedule_main(self, fn):
+ self.root.after(0, fn)
+
+ def run(self):
+ """Run the application (already in mainloop)"""
+ pass
+
+ def _on_right_click(self, event):
+ # Build a dynamic menu of all available skins
+ menu = tk.Menu(self.root, tearoff=0)
+ for skin_name in SkinLoader.list_skins():
+ menu.add_command(
+ label=skin_name,
+ command=lambda name=skin_name: self._change_skin(name)
+ )
+ menu.add_separator()
+ menu.add_command(label="Quit", command=lambda: (self.root.destroy(), os._exit(0)))
+ menu.tk_popup(event.x_root, event.y_root)
+
+ def _change_skin(self, skin_name):
+ print(f"Changing skin to: {skin_name}")
+ self.load_skin(skin_name)
+ self.current_state = 'idle'
+ self.frame_idx = 0
+ else:
+ from PySide6.QtCore import Qt, QTimer, QPoint
+ from PySide6.QtGui import QAction, QCursor, QImage, QPixmap
+ from PySide6.QtWidgets import QApplication, QLabel, QMenu, QWidget
+
+ class _LinuxPetLabel(QLabel):
+ def __init__(self, pet):
+ super().__init__()
+ self.pet = pet
+ self.drag_offset = None
+
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self.drag_offset = event.globalPosition().toPoint() - self.pet.window.frameGeometry().topLeft()
+ event.accept()
+ return
+ if event.button() == Qt.RightButton:
+ self.pet._show_context_menu(event.globalPosition().toPoint())
+ event.accept()
+ return
+ super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event):
+ if self.drag_offset is not None and (event.buttons() & Qt.LeftButton):
+ self.pet.window.move(event.globalPosition().toPoint() - self.drag_offset)
+ self.pet._reposition_toast()
+ event.accept()
+ return
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self.drag_offset = None
+ super().mouseReleaseEvent(event)
+
+ def mouseDoubleClickEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ QApplication.instance().quit()
+ event.accept()
+ return
+ super().mouseDoubleClickEvent(event)
+
+
+ class LinuxPet(PetBase):
+ def __init__(self, skin_name=None):
+ self.app = QApplication.instance() or QApplication(sys.argv)
+ self.available_skins = SkinLoader.list_skins()
+ self.load_skin(skin_name)
+
+ screen = self.app.primaryScreen()
+ screen_geo = screen.availableGeometry() if screen else None
+ if screen_geo:
+ x_pos = screen_geo.right() - self.display_width - 72
+ y_pos = screen_geo.bottom() - self.display_height - 120
+ else:
+ x_pos, y_pos = 1200, 700
+
+ self.window = QWidget()
+ self.window.setWindowFlags(
+ Qt.FramelessWindowHint |
+ Qt.WindowStaysOnTopHint |
+ Qt.Tool
+ )
+ self.window.setAttribute(Qt.WA_TranslucentBackground, True)
+ self.window.setAttribute(Qt.WA_ShowWithoutActivating, True)
+ self.window.resize(self.display_width, self.display_height)
+ self.window.move(x_pos, y_pos)
+
+ self.label = _LinuxPetLabel(self)
+ self.label.setParent(self.window)
+ self.label.setGeometry(0, 0, self.display_width, self.display_height)
+ self.label.setAttribute(Qt.WA_TranslucentBackground, True)
+ self.label.setStyleSheet('background: transparent;')
+ self.label.setScaledContents(True)
+
+ self.current_state = 'idle'
+ self.frame_idx = 0
+ self.toast_window = None
+ self.toast_label = None
+ self.toast_pixmap = None
+
+ self.anim_timer = QTimer()
+ self.anim_timer.timeout.connect(self._animate)
+ self._restart_animation_timer()
+
+ self.window.show()
+ self._start_server()
+
+ print(f"✓ Linux PySide6 Pet started at ({x_pos}, {y_pos})")
+ print(f" Animations: {', '.join(self.animations.keys())}")
+
+ def _pil_to_qpixmap(self, pil_img):
+ buffer = io.BytesIO()
+ pil_img.save(buffer, format='PNG')
+ qimage = QImage.fromData(buffer.getvalue(), 'PNG')
+ return QPixmap.fromImage(qimage)
+
+ def load_skin(self, skin_name=None):
+ available_skins = SkinLoader.list_skins()
+ if not available_skins:
+ raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
+
+ if skin_name is None or skin_name not in available_skins:
+ skin_name = available_skins[0]
+
+ skin_path = os.path.join(SKINS_DIR, skin_name)
+ self.skin_config = SkinLoader.load_skin(skin_path)
+
+ display_size = self.skin_config.get('size', {})
+ self.display_width = display_size.get('width', 128)
+ self.display_height = display_size.get('height', 128)
+
+ self.animations = {}
+ for anim_name, anim_config in self.skin_config['animations'].items():
+ pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
+ qt_frames = []
+ for frame in pil_frames:
+ if frame.mode != 'RGBA':
+ frame = frame.convert('RGBA')
+ scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
+ qt_frames.append(self._pil_to_qpixmap(scaled))
+
+ self.animations[anim_name] = {
+ 'frames': qt_frames,
+ 'fps': anim_config.get('sprite', {}).get('fps', 6)
+ }
+
+ if hasattr(self, 'window'):
+ self.window.resize(self.display_width, self.display_height)
+ self.label.setGeometry(0, 0, self.display_width, self.display_height)
+ self._animate(force=True)
+ self._reposition_toast()
+
+ def _restart_animation_timer(self):
+ anim = self.animations.get(self.current_state) or next(iter(self.animations.values()))
+ fps = max(1, anim.get('fps', 6))
+ self.anim_timer.start(int(1000 / fps))
+
+ def _animate(self, force=False):
+ if self.current_state not in self.animations:
+ return
+ anim = self.animations[self.current_state]
+ frames = anim['frames']
+ if not frames:
+ return
+ if force:
+ self.frame_idx = 0
+ self.label.setPixmap(frames[self.frame_idx])
+ self.frame_idx = (self.frame_idx + 1) % len(frames)
+
+ def set_state(self, state):
+ if state in self.animations and state != self.current_state:
+ self.current_state = state
+ self.frame_idx = 0
+ self._restart_animation_timer()
+ print(f"→ State: {state}")
+
+ def _show_context_menu(self, global_pos):
+ menu = QMenu(self.window)
+ for skin_name in SkinLoader.list_skins():
+ action = QAction(skin_name, menu)
+ action.triggered.connect(lambda checked=False, name=skin_name: self._change_skin(name))
+ menu.addAction(action)
+ menu.addSeparator()
+ quit_action = QAction('Quit', menu)
+ quit_action.triggered.connect(QApplication.instance().quit)
+ menu.addAction(quit_action)
+ menu.popup(global_pos)
+
+ def _compute_toast_geometry(self, bubble_width, bubble_height, tail_x, tail_y):
+ pet_pos = self.window.frameGeometry().topLeft()
+ anchor_x = pet_pos.x() + int(self.display_width * 0.75)
+ anchor_y = pet_pos.y() + int(self.display_height * 0.15)
+ return anchor_x - tail_x, anchor_y - tail_y - bubble_height // 2
+
+ def show_toast(self, message):
+ if self.toast_window:
+ self.toast_window.close()
+ self.toast_window = None
+ self.toast_label = None
+ self.toast_pixmap = None
+
+ bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
+ bubble_pil = bubble_info['image']
+ bubble_width, bubble_height = bubble_info['size']
+ tail_x, tail_y = bubble_info['tail_tip']
+ self.toast_pixmap = self._pil_to_qpixmap(bubble_pil)
+
+ self.toast_window = QWidget()
+ self.toast_window.setWindowFlags(
+ Qt.FramelessWindowHint |
+ Qt.WindowStaysOnTopHint |
+ Qt.Tool |
+ Qt.WindowTransparentForInput
+ )
+ self.toast_window.setAttribute(Qt.WA_TranslucentBackground, True)
+ self.toast_window.setAttribute(Qt.WA_ShowWithoutActivating, True)
+ self.toast_window.resize(bubble_width, bubble_height)
+
+ self.toast_label = QLabel(self.toast_window)
+ self.toast_label.setGeometry(0, 0, bubble_width, bubble_height)
+ self.toast_label.setPixmap(self.toast_pixmap)
+ self.toast_label.setAttribute(Qt.WA_TranslucentBackground, True)
+ self.toast_label.setStyleSheet('background: transparent;')
+
+ toast_x, toast_y = self._compute_toast_geometry(bubble_width, bubble_height, tail_x, tail_y)
+ self.toast_window.move(toast_x, toast_y)
+ self.toast_window.show()
+
+ QTimer.singleShot(3000, self._hide_toast)
+ print(f"Toast: {message}")
+
+ def _reposition_toast(self):
+ if not self.toast_window:
+ return
+ label_pixmap = self.toast_label.pixmap() if self.toast_label else None
+ if label_pixmap is None:
+ return
+ bubble_width = label_pixmap.width()
+ bubble_height = label_pixmap.height()
+ toast_x, toast_y = self._compute_toast_geometry(
+ bubble_width,
+ bubble_height,
+ bubble_width // 2,
+ bubble_height
+ )
+ self.toast_window.move(toast_x, toast_y)
+
+ def _hide_toast(self):
+ if self.toast_window:
+ self.toast_window.close()
+ self.toast_window = None
+ self.toast_label = None
+ self.toast_pixmap = None
+
+ def _schedule_main(self, fn):
+ QTimer.singleShot(0, fn)
+
+ def _change_skin(self, skin_name):
+ print(f"Changing skin to: {skin_name}")
+ self.load_skin(skin_name)
+ self.current_state = 'idle'
+ self.frame_idx = 0
+ self._restart_animation_timer()
+
+ def run(self):
+ self.app.exec()
+
+if __name__ == '__main__':
+ # Singleton: if port already in use, another instance is running
+ import socket
+ _s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ _s.connect(('127.0.0.1', PORT))
+ _s.close()
+ print(f'⚠ Pet already running on port {PORT}, exiting.')
+ sys.exit(0)
+ except ConnectionRefusedError:
+ pass
+
+ if sys.platform == 'darwin':
+ pet = MacPet('vita')
+ pet.run()
+ elif sys.platform.startswith('win'):
+ pet = WinPet('vita')
+ else:
+ pet = LinuxPet('vita')
+ pet.run()
+
diff --git a/frontends/dingtalkapp.py b/frontends/dingtalkapp.py
new file mode 100644
index 000000000..577bfc420
--- /dev/null
+++ b/frontends/dingtalkapp.py
@@ -0,0 +1,151 @@
+import asyncio, json, os, sys, threading, time
+import requests
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from agentmain import GeneraticAgent
+from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
+from llmcore import mykeys
+
+try:
+ from dingtalk_stream import AckMessage, CallbackHandler, Credential, DingTalkStreamClient
+ from dingtalk_stream.chatbot import ChatbotMessage
+except Exception:
+ print("Please install dingtalk-stream to use DingTalk: pip install dingtalk-stream")
+ sys.exit(1)
+
+agent = GeneraticAgent(); agent.verbose = False
+CLIENT_ID = str(mykeys.get("dingtalk_client_id", "") or "").strip()
+CLIENT_SECRET = str(mykeys.get("dingtalk_client_secret", "") or "").strip()
+ALLOWED = {str(x).strip() for x in mykeys.get("dingtalk_allowed_users", []) if str(x).strip()}
+USER_TASKS = {}
+
+
+class DingTalkApp(AgentChatMixin):
+ label, source, split_limit = "DingTalk", "dingtalk", 1800
+
+ def __init__(self):
+ super().__init__(agent, USER_TASKS)
+ self.client, self.access_token, self.token_expiry, self.background_tasks = None, None, 0, set()
+
+ async def _get_access_token(self):
+ if self.access_token and time.time() < self.token_expiry:
+ return self.access_token
+
+ def _fetch():
+ resp = requests.post("https://api.dingtalk.com/v1.0/oauth2/accessToken", json={"appKey": CLIENT_ID, "appSecret": CLIENT_SECRET}, timeout=20)
+ resp.raise_for_status()
+ return resp.json()
+
+ last_err = None
+ for attempt in range(2):
+ try:
+ data = await asyncio.to_thread(_fetch)
+ self.access_token = data.get("accessToken")
+ self.token_expiry = time.time() + int(data.get("expireIn", 7200)) - 60
+ return self.access_token
+ except Exception as e:
+ last_err = e
+ if attempt == 0:
+ await asyncio.sleep(1)
+ print(f"[DingTalk] token error after retry: {last_err}")
+ return None
+
+ async def _send_batch_message(self, chat_id, msg_key, msg_param):
+ token = await self._get_access_token()
+ if not token:
+ return False
+ headers = {"x-acs-dingtalk-access-token": token}
+ if chat_id.startswith("group:"):
+ url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
+ payload = {"robotCode": CLIENT_ID, "openConversationId": chat_id[6:], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
+ else:
+ url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
+ payload = {"robotCode": CLIENT_ID, "userIds": [chat_id], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
+
+ def _post():
+ resp = requests.post(url, json=payload, headers=headers, timeout=20)
+ body = resp.text
+ if resp.status_code != 200:
+ raise RuntimeError(f"HTTP {resp.status_code}: {body[:300]}")
+ result = resp.json() if "json" in resp.headers.get("content-type", "") else {}
+ errcode = result.get("errcode")
+ if errcode not in (None, 0):
+ raise RuntimeError(f"API errcode={errcode}: {body[:300]}")
+ return True
+
+ try:
+ return await asyncio.to_thread(_post)
+ except Exception as e:
+ print(f"[DingTalk] send error: {e}")
+ return False
+
+ async def send_text(self, chat_id, content):
+ for part in split_text(content, self.split_limit):
+ await self._send_batch_message(chat_id, "sampleMarkdown", {"text": part, "title": "Agent Reply"})
+
+ async def on_message(self, content, sender_id, sender_name, conversation_type=None, conversation_id=None):
+ try:
+ if not content:
+ return
+ if not public_access(ALLOWED) and sender_id not in ALLOWED:
+ print(f"[DingTalk] unauthorized user: {sender_id}")
+ return
+ is_group = conversation_type == "2" and conversation_id
+ chat_id = f"group:{conversation_id}" if is_group else sender_id
+ print(f"[DingTalk] message from {sender_name} ({sender_id}): {content}")
+ 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)
+ except Exception:
+ import traceback
+ print("[DingTalk] handle_message error")
+ traceback.print_exc()
+
+ async def start(self):
+ self.client = DingTalkStreamClient(Credential(CLIENT_ID, CLIENT_SECRET))
+ self.client.register_callback_handler(ChatbotMessage.TOPIC, _DingTalkHandler(self))
+ print("[DingTalk] bot starting...")
+ delay, max_delay = 5, 300
+ while True:
+ started_at = time.monotonic()
+ try:
+ await self.client.start()
+ except Exception as e:
+ print(f"[DingTalk] stream error: {e}")
+ # any session that lived >=60s is treated as healthy -> reset backoff
+ if time.monotonic() - started_at >= 60:
+ delay = 5
+ print(f"[DingTalk] reconnect in {delay}s...")
+ await asyncio.sleep(delay)
+ delay = min(delay * 2, max_delay)
+
+
+class _DingTalkHandler(CallbackHandler):
+ def __init__(self, app):
+ super().__init__()
+ self.app = app
+
+ async def process(self, message):
+ try:
+ chatbot_msg = ChatbotMessage.from_dict(message.data)
+ text = getattr(getattr(chatbot_msg, "text", None), "content", "") or ""
+ extensions = getattr(chatbot_msg, "extensions", None) or {}
+ recognition = ((extensions.get("content") or {}).get("recognition") or "").strip() if isinstance(extensions, dict) else ""
+ if not (text := text.strip()):
+ text = recognition or str((message.data.get("text", {}) or {}).get("content", "") or "").strip()
+ sender_id = str(getattr(chatbot_msg, "sender_staff_id", None) or getattr(chatbot_msg, "sender_id", None) or "unknown")
+ sender_name = getattr(chatbot_msg, "sender_nick", None) or "Unknown"
+ await self.app.on_message(text, sender_id, sender_name, message.data.get("conversationType"), message.data.get("conversationId") or message.data.get("openConversationId"))
+ except Exception as e:
+ print(f"[DingTalk] callback error: {e}")
+ return AckMessage.STATUS_OK, "OK"
+
+
+if __name__ == "__main__":
+ _LOCK_SOCK = ensure_single_instance(19530, "DingTalk")
+ require_runtime(agent, "DingTalk", dingtalk_client_id=CLIENT_ID, dingtalk_client_secret=CLIENT_SECRET)
+ redirect_log(__file__, "dingtalkapp.log", "DingTalk", ALLOWED)
+ threading.Thread(target=agent.run, daemon=True).start()
+ asyncio.run(DingTalkApp().start())
diff --git a/frontends/export_cmd.py b/frontends/export_cmd.py
new file mode 100644
index 000000000..4d030c507
--- /dev/null
+++ b/frontends/export_cmd.py
@@ -0,0 +1,78 @@
+"""`/export` command: export last assistant reply / locate full conversation log.
+Pure functions, no Qt deps. UI wiring lives in the frontend file.
+"""
+import os
+import re
+import sys
+from datetime import datetime
+
+from continue_cmd import _pairs, _assistant_text
+
+_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
+_BACKTICK_RUN_RE = re.compile(r'`+')
+
+
+def wrap_for_clipboard(text, language='markdown'):
+ """Wrap text in a markdown code fence that survives nested fences.
+
+ CommonMark closes a fenced block on a line whose backtick run is at least
+ as long as the opening one. Pick `max(3, longest_inner_run + 1)` so any
+ backticks inside `text` are strictly shorter than the outer fence.
+ """
+ longest = max((len(m.group(0)) for m in _BACKTICK_RUN_RE.finditer(text)), default=0)
+ fence = '`' * max(3, longest + 1)
+ return f"{fence}{language}\n{text}\n{fence}"
+
+
+def last_assistant_text(agent):
+ """Last assistant reply as joined plain text from `agent.log_path`.
+
+ Reads the model_responses log, takes the most recent === Response === block,
+ and joins only the `text` fields (skips thinking/tool_use/tool_result).
+
+ Returns None when:
+ - the LLM backend's history is empty (fresh agent or just-`/new`'d —
+ log_path may still hold prior-session content that no longer belongs
+ to the current conversation)
+ - the log is missing or unreadable
+ - there's no Response yet, or the last Response holds no text blocks
+ (e.g. tool-only turn)
+
+ OS errors are logged to stderr — small failure radius, the UI falls back
+ gracefully.
+ """
+ if not (agent.llmclient and agent.llmclient.backend.history):
+ return None
+ log = agent.log_path
+ if not os.path.isfile(log):
+ return None
+ try:
+ with open(log, encoding='utf-8', errors='replace') as f:
+ content = f.read()
+ except OSError as e:
+ print(f"[export_cmd] failed to read {log}: {e}", file=sys.stderr)
+ return None
+ pairs = _pairs(content)
+ if not pairs:
+ return None
+ text = _assistant_text(pairs[-1][1])
+ return text if text.strip() else None
+
+
+def export_to_temp(text, name):
+ """Write text to temp/.md, overwriting on collision. Returns full path.
+
+ `name` is sanitized via os.path.basename to keep the write inside temp/.
+ Empty/whitespace name falls back to a timestamp. `.md` is appended if
+ the user-supplied name has no extension.
+ """
+ os.makedirs(_TEMP_DIR, exist_ok=True)
+ safe = os.path.basename((name or '').strip())
+ if not safe:
+ safe = f"export_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
+ if not os.path.splitext(safe)[1]:
+ safe = safe + '.md'
+ path = os.path.join(_TEMP_DIR, safe)
+ with open(path, 'w', encoding='utf-8') as f:
+ f.write(text)
+ return path
diff --git a/frontends/fsapp.py b/frontends/fsapp.py
new file mode 100644
index 000000000..ad959f660
--- /dev/null
+++ b/frontends/fsapp.py
@@ -0,0 +1,880 @@
+import argparse, asyncio, importlib.util, json, os, queue as Q, re, sys, threading, time, uuid
+from pathlib import Path
+
+PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, PROJECT_ROOT)
+os.chdir(PROJECT_ROOT)
+
+import traceback
+import lark_oapi as lark
+from lark_oapi.api.im.v1 import *
+
+
+def _ensure_dir(path):
+ path = Path(path)
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def _workspace_root_dir():
+ root = os.environ.get("GA_WORKSPACE_ROOT")
+ if root:
+ return _ensure_dir(Path(root).expanduser().resolve())
+ return _ensure_dir(Path(PROJECT_ROOT).resolve())
+
+
+def _workspace_config_dir(root=None):
+ base = Path(root).expanduser().resolve() if root else _workspace_root_dir()
+ if base.name == "ga_config":
+ return _ensure_dir(base)
+ return _ensure_dir(base / "ga_config")
+
+
+def _load_dict_config(path):
+ path = Path(path)
+ if not path.exists():
+ return None
+ try:
+ if path.suffix == ".py":
+ mod_name = f"_fs_mykey_{uuid.uuid4().hex}"
+ spec = importlib.util.spec_from_file_location(mod_name, path)
+ if not spec or not spec.loader:
+ return None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ data = {k: v for k, v in vars(module).items() if not k.startswith("_")}
+ else:
+ with open(path, encoding="utf-8") as f:
+ data = json.load(f)
+ return data if isinstance(data, dict) else None
+ except Exception as e:
+ print(f"[ERROR] load config failed {path}: {e}")
+ return None
+
+
+def _resolve_mykey_path():
+ workspace_root = _workspace_root_dir()
+ config_root = _workspace_config_dir(workspace_root)
+ candidates = [
+ config_root / "mykey.json",
+ config_root / "mykey.py",
+ workspace_root / "mykey.json",
+ workspace_root / "mykey.py",
+ Path(PROJECT_ROOT) / "mykey.json",
+ Path(PROJECT_ROOT) / "mykey.py",
+ ]
+ for candidate in candidates:
+ if _load_dict_config(candidate):
+ return candidate
+ return candidates[0]
+
+
+def _ensure_runtime_paths():
+ workspace_root = _workspace_root_dir()
+ config_root = _workspace_config_dir(workspace_root)
+ os.environ.setdefault("GA_WORKSPACE_ROOT", str(workspace_root))
+ os.environ.setdefault("GA_USER_DATA_DIR", str(config_root))
+ return str(workspace_root), str(config_root)
+
+
+_ensure_runtime_paths()
+from agentmain import GeneraticAgent
+from frontends.chatapp_common import AgentChatMixin, FILE_HINT, split_text
+
+_TAG_PATS = [r"<" + t + r">.*?" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
+_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
+_AUDIO_EXTS = {".opus", ".mp3", ".wav", ".m4a", ".aac"}
+_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
+_FILE_TYPE_MAP = {
+ ".opus": "opus",
+ ".mp4": "mp4",
+ ".pdf": "pdf",
+ ".doc": "doc",
+ ".docx": "doc",
+ ".xls": "xls",
+ ".xlsx": "xls",
+ ".ppt": "ppt",
+ ".pptx": "ppt",
+}
+_MSG_TYPE_MAP = {"image": "[image]", "audio": "[audio]", "file": "[file]", "media": "[media]", "sticker": "[sticker]"}
+
+TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
+MEDIA_DIR = os.path.join(TEMP_DIR, "feishu_media")
+os.makedirs(MEDIA_DIR, exist_ok=True)
+
+
+_TRUNC_TAIL = 300 # 截断兜底时保留原文尾部字符数
+_DEDUP_TTL_SEC = 10 * 60
+_DEDUP_MAX = 2000
+_DEDUP_LOCK = threading.Lock()
+_SEEN_MESSAGES = {}
+
+
+def _claim_message_once(message_id):
+ """Best-effort cross-platform dedup for Feishu reconnect redeliveries."""
+ if not message_id:
+ return True
+ now = time.time()
+ with _DEDUP_LOCK:
+ expired = [mid for mid, ts in _SEEN_MESSAGES.items() if now - ts > _DEDUP_TTL_SEC]
+ for mid in expired:
+ _SEEN_MESSAGES.pop(mid, None)
+ if len(_SEEN_MESSAGES) > _DEDUP_MAX:
+ for mid, _ in sorted(_SEEN_MESSAGES.items(), key=lambda item: item[1])[:len(_SEEN_MESSAGES) - _DEDUP_MAX]:
+ _SEEN_MESSAGES.pop(mid, None)
+ if message_id in _SEEN_MESSAGES:
+ return False
+ _SEEN_MESSAGES[message_id] = now
+ return True
+
+
+def _clean(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()
+
+
+def _extract_files(text):
+ return re.findall(r"\[FILE:([^\]]+)\]", text or "")
+
+
+def _strip_files(text):
+ return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
+
+
+def _display_text(text):
+ cleaned = _strip_files(_clean(text))
+ if cleaned:
+ return cleaned
+ tail = (text or "").strip()[-_TRUNC_TAIL:]
+ return "⚠️ 模型输出被截断或为空" + (f"\n…{tail}" if tail else "")
+
+
+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 _parse_json(raw):
+ if not raw:
+ return {}
+ try:
+ return json.loads(raw)
+ except Exception:
+ return {}
+
+
+def _extract_share_card_content(content_json, msg_type):
+ parts = []
+ if msg_type == "share_chat":
+ parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
+ elif msg_type == "share_user":
+ parts.append(f"[shared user: {content_json.get('user_id', '')}]")
+ elif msg_type == "interactive":
+ parts.extend(_extract_interactive_content(content_json))
+ elif msg_type == "share_calendar_event":
+ parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]")
+ elif msg_type == "system":
+ parts.append("[system message]")
+ elif msg_type == "merge_forward":
+ parts.append("[merged forward messages]")
+ return "\n".join([p for p in parts if p]).strip() or f"[{msg_type}]"
+
+
+def _extract_interactive_content(content):
+ parts = []
+ if isinstance(content, str):
+ try:
+ content = json.loads(content)
+ except Exception:
+ return [content] if content.strip() else []
+ if not isinstance(content, dict):
+ return parts
+ title = content.get("title")
+ if isinstance(title, dict):
+ title_text = title.get("content", "") or title.get("text", "")
+ if title_text:
+ parts.append(f"title: {title_text}")
+ elif isinstance(title, str) and title:
+ parts.append(f"title: {title}")
+ elements = content.get("elements", [])
+ if isinstance(elements, list):
+ for row in elements:
+ if isinstance(row, dict):
+ parts.extend(_extract_element_content(row))
+ elif isinstance(row, list):
+ for el in row:
+ parts.extend(_extract_element_content(el))
+ card = content.get("card", {})
+ if card:
+ parts.extend(_extract_interactive_content(card))
+ header = content.get("header", {})
+ if isinstance(header, dict):
+ header_title = header.get("title", {})
+ if isinstance(header_title, dict):
+ header_text = header_title.get("content", "") or header_title.get("text", "")
+ if header_text:
+ parts.append(f"title: {header_text}")
+ return [p for p in parts if p]
+
+
+def _extract_element_content(element):
+ parts = []
+ if not isinstance(element, dict):
+ return parts
+ tag = element.get("tag", "")
+ if tag in ("markdown", "lark_md"):
+ content = element.get("content", "")
+ if content:
+ parts.append(content)
+ elif tag == "div":
+ text = element.get("text", {})
+ if isinstance(text, dict):
+ text_content = text.get("content", "") or text.get("text", "")
+ if text_content:
+ parts.append(text_content)
+ elif isinstance(text, str) and text:
+ parts.append(text)
+ for field in element.get("fields", []) or []:
+ if isinstance(field, dict):
+ field_text = field.get("text", {})
+ if isinstance(field_text, dict):
+ content = field_text.get("content", "") or field_text.get("text", "")
+ if content:
+ parts.append(content)
+ elif tag == "a":
+ href = element.get("href", "")
+ text = element.get("text", "")
+ if href:
+ parts.append(f"link: {href}")
+ if text:
+ parts.append(text)
+ elif tag == "button":
+ text = element.get("text", {})
+ if isinstance(text, dict):
+ content = text.get("content", "") or text.get("text", "")
+ if content:
+ parts.append(content)
+ url = element.get("url", "") or (element.get("multi_url", {}) or {}).get("url", "")
+ if url:
+ parts.append(f"link: {url}")
+ elif tag == "img":
+ alt = element.get("alt", {})
+ if isinstance(alt, dict):
+ parts.append(alt.get("content", "[image]") or "[image]")
+ else:
+ parts.append("[image]")
+ for child in element.get("elements", []) or []:
+ parts.extend(_extract_element_content(child))
+ for col in element.get("columns", []) or []:
+ for child in (col.get("elements", []) if isinstance(col, dict) else []):
+ parts.extend(_extract_element_content(child))
+ return parts
+
+
+def _extract_post_content(content_json):
+ def _parse_block(block):
+ if not isinstance(block, dict) or not isinstance(block.get("content"), list):
+ return None, []
+ texts, images = [], []
+ if block.get("title"):
+ texts.append(block.get("title"))
+ for row in block["content"]:
+ if not isinstance(row, list):
+ continue
+ for el in row:
+ if not isinstance(el, dict):
+ continue
+ tag = el.get("tag")
+ if tag in ("text", "a"):
+ texts.append(el.get("text", ""))
+ elif tag == "at":
+ texts.append(f"@{el.get('user_name', 'user')}")
+ elif tag == "img" and el.get("image_key"):
+ images.append(el["image_key"])
+ text = " ".join([t for t in texts if t]).strip()
+ return text or None, images
+
+ root = content_json
+ if isinstance(root, dict) and isinstance(root.get("post"), dict):
+ root = root["post"]
+ if not isinstance(root, dict):
+ return "", []
+ if "content" in root:
+ text, imgs = _parse_block(root)
+ if text or imgs:
+ return text or "", imgs
+ for key in ("zh_cn", "en_us", "ja_jp"):
+ if key in root:
+ text, imgs = _parse_block(root[key])
+ if text or imgs:
+ return text or "", imgs
+ for val in root.values():
+ if isinstance(val, dict):
+ text, imgs = _parse_block(val)
+ if text or imgs:
+ return text or "", imgs
+ return "", []
+
+
+AGENT_TIMEOUT_SEC = 900
+
+agent = None
+agent_error = None
+agent_thread = None
+client, user_tasks, app = None, {}, None
+agent_lock = threading.Lock()
+
+
+def _load_config():
+ path = _resolve_mykey_path()
+ if not path or not path.exists():
+ return {}, str(path or "")
+ try:
+ data = _load_dict_config(path)
+ return data if isinstance(data, dict) else {}, str(path)
+ except Exception as e:
+ print(f"[ERROR] load mykey failed {path}: {e}")
+ return {}, str(path)
+
+
+def _feishu_config():
+ cfg, path = _load_config()
+ app_id = str(cfg.get("fs_app_id", "") or "").strip()
+ app_secret = str(cfg.get("fs_app_secret", "") or "").strip()
+ allowed = _to_allowed_set(cfg.get("fs_allowed_users", []))
+ return app_id, app_secret, allowed, (not allowed or "*" in allowed), path
+
+
+APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH = _feishu_config()
+
+
+def get_agent():
+ global agent, agent_error, agent_thread
+ with agent_lock:
+ if agent is not None:
+ return agent
+ if agent_error:
+ raise RuntimeError(agent_error)
+ try:
+ agent = GeneraticAgent()
+ agent_thread = threading.Thread(target=agent.run, daemon=True)
+ agent_thread.start()
+ return agent
+ except Exception as e:
+ agent_error = str(e)
+ raise
+
+
+def create_client():
+ return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build()
+
+
+def _mask_secret(value):
+ value = str(value or "")
+ if len(value) <= 8:
+ return "*" * len(value)
+ return value[:4] + "*" * (len(value) - 8) + value[-4:]
+
+
+def check_config(init_agent=False):
+ app_id, app_secret, allowed, public_access, path = _feishu_config()
+ result = {
+ "config_path": path,
+ "app_id": app_id,
+ "app_secret": _mask_secret(app_secret),
+ "app_secret_present": bool(app_secret),
+ "public_access": public_access,
+ "allowed_users": sorted(allowed),
+ "ready": bool(app_id and app_secret),
+ }
+ if init_agent:
+ try:
+ ga = get_agent()
+ result["agent_ready"] = True
+ result["llm_count"] = len(ga.list_llms()) if hasattr(ga, "list_llms") else 0
+ result["current_llm"] = ga.get_llm_name() if getattr(ga, "llmclient", None) else ""
+ except Exception as e:
+ result["agent_ready"] = False
+ result["agent_error"] = str(e)
+ return result
+
+
+def _card_raw(elements):
+ return json.dumps({
+ "schema": "2.0",
+ "config": {"streaming_mode": False, "width_mode": "fill"},
+ "body": {"elements": elements},
+ }, ensure_ascii=False)
+
+
+def _card(text):
+ return _card_raw([{"tag": "markdown", "content": text}])
+
+
+def _send_raw(receive_id, payload, msg_type, rtype):
+ try:
+ body = CreateMessageRequest.builder().receive_id_type(rtype).request_body(
+ CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build()
+ ).build()
+ r = client.im.v1.message.create(body)
+ if r.success():
+ return r.data.message_id if r.data else None
+ print(f"发送失败: {r.code}, {r.msg}")
+ except Exception as e:
+ print(f"[ERROR] send_message failed: {e}")
+ traceback.print_exc()
+ return None
+
+
+def _patch_card(message_id, card_json):
+ try:
+ body = PatchMessageRequest.builder().message_id(message_id).request_body(
+ PatchMessageRequestBody.builder().content(card_json).build()
+ ).build()
+ r = client.im.v1.message.patch(body)
+ if not r.success():
+ print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}")
+ return r.success()
+ except Exception as e:
+ print(f"[ERROR] patch_card exception: {e}")
+ traceback.print_exc()
+ return False
+
+
+def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"):
+ if use_card:
+ return _send_raw(receive_id, _card(content), "interactive", receive_id_type)
+ if msg_type == "text":
+ return _send_raw(receive_id, json.dumps({"text": content}, ensure_ascii=False), "text", receive_id_type)
+ return _send_raw(receive_id, content, msg_type, receive_id_type)
+
+
+def update_message(message_id, content):
+ return _patch_card(message_id, _card(content))
+
+
+def _upload_image_sync(file_path):
+ try:
+ with open(file_path, "rb") as f:
+ request = CreateImageRequest.builder().request_body(
+ CreateImageRequestBody.builder().image_type("message").image(f).build()
+ ).build()
+ response = client.im.v1.image.create(request)
+ if response.success():
+ return response.data.image_key
+ print(f"[ERROR] upload image failed: {response.code}, {response.msg}")
+ except Exception as e:
+ print(f"[ERROR] upload image failed {file_path}: {e}")
+ return None
+
+
+def _upload_file_sync(file_path):
+ ext = os.path.splitext(file_path)[1].lower()
+ file_type = _FILE_TYPE_MAP.get(ext, "stream")
+ file_name = os.path.basename(file_path)
+ try:
+ with open(file_path, "rb") as f:
+ request = CreateFileRequest.builder().request_body(
+ CreateFileRequestBody.builder().file_type(file_type).file_name(file_name).file(f).build()
+ ).build()
+ response = client.im.v1.file.create(request)
+ if response.success():
+ return response.data.file_key
+ print(f"[ERROR] upload file failed: {response.code}, {response.msg}")
+ except Exception as e:
+ print(f"[ERROR] upload file failed {file_path}: {e}")
+ return None
+
+
+def _download_image_sync(message_id, image_key):
+ try:
+ request = GetMessageResourceRequest.builder().message_id(message_id).file_key(image_key).type("image").build()
+ response = client.im.v1.message_resource.get(request)
+ if response.success():
+ data = response.file.read() if hasattr(response.file, "read") else response.file
+ return data, response.file_name
+ print(f"[ERROR] download image failed: {response.code}, {response.msg}")
+ except Exception as e:
+ print(f"[ERROR] download image failed {image_key}: {e}")
+ return None, None
+
+
+def _download_file_sync(message_id, file_key, resource_type="file"):
+ if resource_type == "audio":
+ resource_type = "file"
+ try:
+ request = GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(resource_type).build()
+ response = client.im.v1.message_resource.get(request)
+ if response.success():
+ data = response.file.read() if hasattr(response.file, "read") else response.file
+ return data, response.file_name
+ print(f"[ERROR] download {resource_type} failed: {response.code}, {response.msg}")
+ except Exception as e:
+ print(f"[ERROR] download {resource_type} failed {file_key}: {e}")
+ return None, None
+
+
+def _download_and_save_media(msg_type, content_json, message_id):
+ data, filename = None, None
+ if msg_type == "image":
+ image_key = content_json.get("image_key")
+ if image_key and message_id:
+ data, filename = _download_image_sync(message_id, image_key)
+ if not filename:
+ filename = f"{image_key[:16]}.jpg"
+ elif msg_type in ("audio", "file", "media"):
+ file_key = content_json.get("file_key")
+ if file_key and message_id:
+ data, filename = _download_file_sync(message_id, file_key, msg_type)
+ if not filename:
+ filename = file_key[:16]
+ if msg_type == "audio" and filename and not filename.endswith(".opus"):
+ filename = f"{filename}.opus"
+ if data and filename:
+ file_path = os.path.join(MEDIA_DIR, os.path.basename(filename))
+ with open(file_path, "wb") as f:
+ f.write(data)
+ return file_path, filename
+ return None, None
+
+
+def _describe_media(msg_type, file_path, filename):
+ if msg_type == "image":
+ return f"[image: {filename}]\n[Image: source: {file_path}]"
+ if msg_type == "audio":
+ return f"[audio: {filename}]\n[File: source: {file_path}]"
+ if msg_type in ("file", "media"):
+ return f"[{msg_type}: {filename}]\n[File: source: {file_path}]"
+ return f"[{msg_type}]\n[File: source: {file_path}]"
+
+
+def _send_local_file(receive_id, file_path, receive_id_type="open_id"):
+ if not os.path.isfile(file_path):
+ send_message(receive_id, f"⚠️ 文件不存在: {file_path}", receive_id_type=receive_id_type)
+ return False
+ ext = os.path.splitext(file_path)[1].lower()
+ if ext in _IMAGE_EXTS:
+ image_key = _upload_image_sync(file_path)
+ if image_key:
+ send_message(receive_id, json.dumps({"image_key": image_key}, ensure_ascii=False), msg_type="image", receive_id_type=receive_id_type)
+ return True
+ else:
+ file_key = _upload_file_sync(file_path)
+ if file_key:
+ msg_type = "media" if ext in _AUDIO_EXTS or ext in _VIDEO_EXTS else "file"
+ send_message(receive_id, json.dumps({"file_key": file_key}, ensure_ascii=False), msg_type=msg_type, receive_id_type=receive_id_type)
+ return True
+ send_message(receive_id, f"⚠️ 文件发送失败: {os.path.basename(file_path)}", receive_id_type=receive_id_type)
+ return False
+
+
+def _send_generated_files(receive_id, raw_text, receive_id_type="open_id"):
+ for file_path in _extract_files(raw_text):
+ _send_local_file(receive_id, file_path, receive_id_type)
+
+
+def _build_user_message(message):
+ msg_type = message.message_type
+ message_id = message.message_id
+ content_json = _parse_json(message.content)
+ parts, image_paths = [], []
+ if msg_type == "text":
+ text = str(content_json.get("text", "") or "").strip()
+ if text:
+ parts.append(text)
+ elif msg_type == "post":
+ text, image_keys = _extract_post_content(content_json)
+ if text:
+ parts.append(text)
+ for image_key in image_keys:
+ file_path, filename = _download_and_save_media("image", {"image_key": image_key}, message_id)
+ if file_path and filename:
+ parts.append(_describe_media("image", file_path, filename))
+ image_paths.append(file_path)
+ else:
+ parts.append("[image: download failed]")
+ elif msg_type in ("image", "audio", "file", "media"):
+ file_path, filename = _download_and_save_media(msg_type, content_json, message_id)
+ if file_path and filename:
+ parts.append(_describe_media(msg_type, file_path, filename))
+ if msg_type == "image":
+ image_paths.append(file_path)
+ else:
+ parts.append(f"[{msg_type}: download failed]")
+ elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"):
+ parts.append(_extract_share_card_content(content_json, msg_type))
+ else:
+ parts.append(_MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
+ return "\n".join([p for p in parts if p]).strip(), image_paths
+
+
+def _fmt_tool_call(tc):
+ name = tc.get('tool_name', '?')
+ args = {k: v for k, v in (tc.get('args') or {}).items() if not k.startswith('_')}
+ return f"- `{name}`({json.dumps(args, ensure_ascii=False)[:200]})"
+
+
+def _build_step_detail(resp, tool_calls):
+ """从 LLM response + tool_calls 组装单步展开详情(纯函数)。"""
+ parts = []
+ thinking = (getattr(resp, 'thinking', '') or '').strip() if resp else ''
+ if thinking:
+ parts.append(f"### 💭 Thinking\n{thinking}")
+ if tool_calls:
+ parts.append("### 🛠 Tool Calls\n" + "\n".join(_fmt_tool_call(tc) for tc in tool_calls))
+ content = _display_text((getattr(resp, 'content', '') or '')).strip() if resp else ''
+ if content and content != '...':
+ parts.append(f"### 📝 Output\n{content}")
+ return "\n\n".join(parts)
+
+
+class _TaskCard:
+ """飞书任务卡片:单卡片持续 patch;每步一个独立折叠面板(header 显示 summary,展开看详情)。"""
+ _DETAIL_LIMIT = 8000
+
+ def __init__(self, receive_id, rid_type):
+ self.rid, self.rtype = receive_id, rid_type
+ self.steps = [] # [(summary, detail), ...]
+ self.status = "🤔 思考中..."
+ self.final = None
+ self.msg_id = None
+ self.start_fallback_sent = False
+ self.final_fallback_sent = False
+
+ def _step_panel(self, idx, summary, detail):
+ detail = detail or "_(无输出)_"
+ if len(detail) > self._DETAIL_LIMIT:
+ detail = detail[:self._DETAIL_LIMIT] + f"\n\n…(已截断,共 {len(detail)} 字符)"
+ return {
+ "tag": "collapsible_panel", "expanded": False,
+ "header": {"title": {"tag": "plain_text", "content": f"Turn {idx} · {summary}"}},
+ "elements": [{"tag": "markdown", "content": detail}],
+ }
+
+ def _build(self):
+ els = [{"tag": "markdown", "content": f"**{self.status}**"}]
+ for i, (s, d) in enumerate(self.steps, 1):
+ els.append(self._step_panel(i, s, d))
+ if self.final:
+ els += [{"tag": "hr"}, {"tag": "markdown", "content": self.final}]
+ return _card_raw(els)
+
+ def _push(self):
+ card = self._build()
+ if self.msg_id:
+ ok = _patch_card(self.msg_id, card)
+ else:
+ self.msg_id = _send_raw(self.rid, card, "interactive", self.rtype)
+ ok = bool(self.msg_id)
+ return ok
+
+ def _fallback_text(self, text, *, final=False):
+ attr = "final_fallback_sent" if final else "start_fallback_sent"
+ if getattr(self, attr):
+ return
+ setattr(self, attr, True)
+ send_message(self.rid, text, receive_id_type=self.rtype)
+
+ # ── 公开接口 ──
+
+ def start(self):
+ if not self._push():
+ self._fallback_text("🤔 思考中...")
+
+ def step(self, summary, detail=""):
+ self.steps.append((summary, detail))
+ self.status = f"⏳ 工作中 · Turn {len(self.steps)}"
+ self._push()
+
+ def done(self, text):
+ self.status = "✅ 已完成"
+ self.final = text or "_(无文本输出)_"
+ if not self._push():
+ self._fallback_text(_display_text(text), final=True)
+
+ def fail(self, msg):
+ self.status = f"❌ {msg}"
+ if not self._push():
+ self._fallback_text(f"❌ {msg}", final=True)
+
+
+def _make_task_hook(card, task_id, on_final):
+ """飞书任务 hook:每轮 patch 卡片状态;结束触发 on_final(raw) 处理附件。"""
+ def hook(ctx):
+ try:
+ parent = getattr(ctx.get("self"), "parent", None)
+ if getattr(parent, "_fs_active_task_id", None) != task_id:
+ return
+ if ctx.get('exit_reason'):
+ resp = ctx.get('response')
+ raw = resp.content if hasattr(resp, 'content') else str(resp)
+ on_final(raw)
+ elif ctx.get('summary'):
+ detail = _build_step_detail(ctx.get('response'), ctx.get('tool_calls') or [])
+ card.step(ctx['summary'], detail)
+ except Exception as e:
+ print(f"[fs hook] error: {e}")
+ return hook
+
+
+class FeishuApp(AgentChatMixin):
+ label, source, split_limit = "Feishu", "feishu", 4000
+
+ async def send_text(self, chat_id, content, *, receive_id=None, receive_id_type="open_id", **_):
+ rid = receive_id or chat_id
+ for part in split_text(content, self.split_limit):
+ await asyncio.to_thread(send_message, rid, part, "text", False, receive_id_type)
+
+ async def send_done(self, chat_id, raw_text, *, receive_id=None, receive_id_type="open_id", **_):
+ rid = receive_id or chat_id
+ text = _display_text(raw_text)
+ await asyncio.to_thread(send_message, rid, text, "text", False, receive_id_type)
+ await asyncio.to_thread(_send_generated_files, rid, raw_text, receive_id_type)
+
+ async def run_agent(self, chat_id, text, *, receive_id=None, receive_id_type="open_id", images=None, **_):
+ if self.user_tasks:
+ await self.send_text(chat_id, "当前会话已有任务在运行,请等待完成或发送 /stop 后再试。", receive_id=receive_id, receive_id_type=receive_id_type)
+ return
+ state = {"running": True}
+ self.user_tasks[chat_id] = state
+ rid = receive_id or chat_id
+ task_id = f"{chat_id}_{uuid.uuid4().hex}"
+ hook_key = f"fs_{task_id}"
+ card = _TaskCard(rid, receive_id_type)
+ result = {"raw": None, "sent": False}
+ finish_lock = threading.Lock()
+
+ def _finish(raw):
+ with finish_lock:
+ if result["sent"]:
+ return
+ result["raw"] = raw
+ result["sent"] = True
+ card.done(_display_text(raw))
+ _send_generated_files(rid, raw, receive_id_type=receive_id_type)
+
+ try:
+ await asyncio.to_thread(card.start)
+ if not hasattr(self.agent, '_turn_end_hooks'):
+ self.agent._turn_end_hooks = {}
+ self.agent._turn_end_hooks[hook_key] = _make_task_hook(card, task_id, _finish)
+ self.agent._fs_active_task_id = task_id
+ dq = self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source, images=images or None)
+ start = time.time()
+ while state["running"] and not result["sent"]:
+ try:
+ item = await asyncio.to_thread(dq.get, True, 1)
+ except Q.Empty:
+ item = None
+ if item and "done" in item:
+ await asyncio.to_thread(_finish, item.get("done", ""))
+ break
+ if time.time() - start > AGENT_TIMEOUT_SEC:
+ self.agent.abort()
+ await asyncio.to_thread(card.fail, "任务超时")
+ break
+ if not state["running"] and not result["sent"]:
+ self.agent.abort()
+ await asyncio.to_thread(card.fail, "已停止")
+ except Exception as e:
+ traceback.print_exc()
+ await asyncio.to_thread(card.fail, f"错误: {e}")
+ finally:
+ if getattr(self.agent, "_fs_active_task_id", None) == task_id:
+ try:
+ delattr(self.agent, "_fs_active_task_id")
+ except AttributeError:
+ pass
+ if hasattr(self.agent, '_turn_end_hooks'):
+ self.agent._turn_end_hooks.pop(hook_key, None)
+ self.user_tasks.pop(chat_id, None)
+
+
+def get_app():
+ global app
+ if app is None:
+ app = FeishuApp(get_agent(), user_tasks)
+ return app
+
+
+def _run_async(coro):
+ try:
+ asyncio.run(coro)
+ except Exception:
+ traceback.print_exc()
+
+
+def handle_message(data):
+ event, message, sender = data.event, data.event.message, data.event.sender
+ message_id = getattr(message, "message_id", "") or ""
+ if not _claim_message_once(message_id):
+ print(f"忽略重复飞书消息: {message_id}")
+ return
+ open_id = sender.sender_id.open_id
+ chat_id = message.chat_id
+ if not PUBLIC_ACCESS and open_id not in ALLOWED_USERS:
+ print(f"未授权用户: {open_id}")
+ return
+ user_input, image_paths = _build_user_message(message)
+ if not user_input:
+ if chat_id:
+ send_message(chat_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}", receive_id_type="chat_id")
+ else:
+ send_message(open_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}")
+ return
+ print(f"收到消息 [{open_id}] ({message.message_type}, {len(image_paths)} images): {user_input[:200]}")
+ receive_id = chat_id or open_id
+ receive_id_type = "chat_id" if chat_id else "open_id"
+ chat_key = receive_id
+ if message.message_type == "text" and user_input.startswith("/"):
+ threading.Thread(
+ target=_run_async,
+ args=(get_app().handle_command(chat_key, user_input, receive_id=receive_id, receive_id_type=receive_id_type),),
+ daemon=True,
+ ).start()
+ return
+ threading.Thread(
+ target=_run_async,
+ args=(get_app().run_agent(chat_key, user_input, receive_id=receive_id, receive_id_type=receive_id_type, images=image_paths),),
+ daemon=True,
+ ).start()
+
+
+def main():
+ global client, APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH
+ APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH = _feishu_config()
+ if not APP_ID or not APP_SECRET:
+ print(f"错误: 请在 mykey 配置中填写 fs_app_id 和 fs_app_secret\n配置文件: {CONFIG_PATH}", flush=True)
+ sys.exit(1)
+ handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build()
+ retry_delay = 5
+ while True:
+ try:
+ client = create_client()
+ cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO)
+ print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n配置: {CONFIG_PATH}\n等待消息...\n" + "=" * 50, flush=True)
+ cli.start()
+ retry_delay = 5
+ except KeyboardInterrupt:
+ raise
+ except Exception as e:
+ print(f"[WARN] 飞书长连接断开或启动失败: {e}", flush=True)
+ traceback.print_exc()
+ print(f"[INFO] {retry_delay}s 后重连飞书长连接...", flush=True)
+ time.sleep(retry_delay)
+ retry_delay = min(retry_delay * 2, 120)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="A3Agent Feishu frontend")
+ parser.add_argument("--check", action="store_true", help="只检查飞书配置,不启动长连接")
+ parser.add_argument("--check-agent", action="store_true", help="检查配置并初始化 Agent/LLM")
+ args = parser.parse_args()
+ if args.check or args.check_agent:
+ print(json.dumps(check_config(init_agent=args.check_agent), ensure_ascii=False, indent=2), flush=True)
+ else:
+ main()
diff --git a/frontends/genericagent_acp_bridge.py b/frontends/genericagent_acp_bridge.py
new file mode 100644
index 000000000..7882b6162
--- /dev/null
+++ b/frontends/genericagent_acp_bridge.py
@@ -0,0 +1,375 @@
+import io
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+# Must run BEFORE importing agentmain — it reconfigures stdout at import time,
+# and its submodules may print() during init. We capture the raw binary stdout
+# for ACP JSON-RPC, then redirect the text-mode stdout to stderr so any stray
+# prints from agentmain/llmcore don't pollute the ACP channel.
+if sys.platform == "win32":
+ import msvcrt
+ _stdout_fd = os.dup(sys.__stdout__.fileno())
+ msvcrt.setmode(_stdout_fd, os.O_BINARY)
+ _acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0)
+ msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
+ # Mark the ACP fd as non-inheritable so child processes can't write to it.
+ os.set_inheritable(_stdout_fd, False)
+ # Redirect the original stdout fd to stderr so child processes
+ # (tool calls) don't write into the ACP JSON-RPC channel.
+ os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())
+else:
+ _stdout_fd = os.dup(sys.__stdout__.fileno())
+ os.set_inheritable(_stdout_fd, False)
+ _acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0)
+ os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())
+
+
+class _StdoutToStderrRouter(io.TextIOBase):
+ """Redirect text-mode stdout to stderr so agentmain prints don't leak."""
+ def writable(self): return True
+ def write(self, s):
+ if s:
+ sys.stderr.write(s)
+ sys.stderr.flush()
+ return len(s) if s else 0
+ def flush(self): sys.stderr.flush()
+
+sys.stdout = _StdoutToStderrRouter()
+
+import argparse
+import queue
+import threading
+import traceback
+import uuid
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+
+from agentmain import GeneraticAgent
+
+
+JSONRPC_VERSION = "2.0"
+ACP_PROTOCOL_VERSION = 1
+
+
+def eprint(*args: Any) -> None:
+ print(*args, file=sys.stderr, flush=True)
+
+
+def make_text_block(text: str) -> Dict[str, Any]:
+ return {"type": "text", "text": text}
+
+
+def make_session_update(session_id: str, update: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ "jsonrpc": JSONRPC_VERSION,
+ "method": "session/update",
+ "params": {"sessionId": session_id, "update": update},
+ }
+
+
+def compact_json(obj: Dict[str, Any]) -> str:
+ return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
+
+
+def parse_jsonrpc_line(line: str) -> Optional[Dict[str, Any]]:
+ stripped = line.strip()
+ if not stripped:
+ return None
+ try:
+ obj = json.loads(stripped)
+ except json.JSONDecodeError:
+ return None
+ return obj if isinstance(obj, dict) else None
+
+
+def content_blocks_to_text(blocks: List[Dict[str, Any]]) -> str:
+ parts: List[str] = []
+ for block in blocks:
+ if not isinstance(block, dict):
+ continue
+ block_type = block.get("type")
+ if block_type == "text":
+ text = block.get("text")
+ if isinstance(text, str) and text:
+ parts.append(text)
+ elif block_type == "resource_link":
+ name = block.get("name") or "resource"
+ uri = block.get("uri") or ""
+ desc = block.get("description") or ""
+ parts.append(f"[ResourceLink] {name}: {uri}\n{desc}".strip())
+ elif block_type == "resource":
+ uri = block.get("uri") or "resource"
+ text = block.get("text")
+ if isinstance(text, str) and text:
+ parts.append(f"[Resource] {uri}\n{text}")
+ else:
+ parts.append(f"[Resource] {uri}")
+ elif block_type == "image":
+ uri = block.get("uri") or "inline-image"
+ parts.append(f"[Image omitted] {uri}")
+ else:
+ parts.append(f"[Unsupported content block: {block_type}]")
+ return "\n\n".join(p for p in parts if p).strip()
+
+
+def jsonrpc_error(code: int, message: str, req_id: Any = None, data: Any = None) -> Dict[str, Any]:
+ err: Dict[str, Any] = {"code": code, "message": message}
+ if data is not None:
+ err["data"] = data
+ return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "error": err}
+
+
+def jsonrpc_result(req_id: Any, result: Any) -> Dict[str, Any]:
+ return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "result": result}
+
+
+@dataclass
+class SessionState:
+ session_id: str
+ cwd: str
+ agent: GeneraticAgent
+ current_prompt_id: Any = None
+ prompt_lock: threading.Lock = field(default_factory=threading.Lock)
+
+
+class GenericAgentAcpBridge:
+ def __init__(self, llm_no: int = 0):
+ self.llm_no = llm_no
+ self._json_out = _acp_stdout
+ self._write_lock = threading.Lock()
+ self._sessions: Dict[str, SessionState] = {}
+ self._shutdown = False
+
+ def write_message(self, msg: Dict[str, Any]) -> None:
+ payload = compact_json(msg)
+ raw = (payload + "\n").encode("utf-8")
+ method = msg.get("method", msg.get("id", "?"))
+ eprint(f"[ACP-BRIDGE] >>> {payload[:500]}")
+ try:
+ with self._write_lock:
+ self._json_out.write(raw)
+ self._json_out.flush()
+ except Exception as e:
+ eprint(f"[ACP-BRIDGE] WRITE FAILED: {type(e).__name__}: {e}")
+
+ def new_agent(self) -> GeneraticAgent:
+ agent = GeneraticAgent()
+ agent.next_llm(self.llm_no)
+ agent.verbose = True
+ agent.inc_out = True
+ threading.Thread(target=agent.run, daemon=True).start()
+ return agent
+
+ def handle_initialize(self, req_id: Any, params: Dict[str, Any]) -> None:
+ requested_version = params.get("protocolVersion", ACP_PROTOCOL_VERSION)
+ version = ACP_PROTOCOL_VERSION if requested_version == ACP_PROTOCOL_VERSION else ACP_PROTOCOL_VERSION
+ result = {
+ "protocolVersion": version,
+ "agentCapabilities": {
+ "loadSession": False,
+ "mcpCapabilities": {"http": False, "sse": False},
+ "promptCapabilities": {
+ "image": False,
+ "audio": False,
+ "embeddedContext": False,
+ },
+ "sessionCapabilities": {},
+ },
+ "agentInfo": {
+ "name": "genericagent-acp",
+ "title": "GenericAgent",
+ "version": "0.1.0",
+ },
+ "authMethods": [],
+ }
+ self.write_message(jsonrpc_result(req_id, result))
+
+ def handle_session_new(self, req_id: Any, params: Dict[str, Any]) -> None:
+ cwd = params.get("cwd")
+ if not isinstance(cwd, str) or not cwd:
+ self.write_message(jsonrpc_error(-32602, "cwd is required", req_id))
+ return
+ if not os.path.isabs(cwd):
+ cwd = os.path.abspath(cwd)
+ session_id = f"ga_{uuid.uuid4().hex}"
+ agent = self.new_agent()
+ session = SessionState(session_id=session_id, cwd=cwd, agent=agent)
+ self._sessions[session_id] = session
+ self.write_message(
+ jsonrpc_result(
+ req_id,
+ {
+ "sessionId": session_id,
+ "modes": None,
+ "configOptions": None,
+ },
+ )
+ )
+
+ def handle_session_prompt(self, req_id: Any, params: Dict[str, Any]) -> None:
+ session_id = params.get("sessionId")
+ prompt_blocks = params.get("prompt")
+ session = self._sessions.get(session_id)
+ if session is None:
+ self.write_message(jsonrpc_error(-32602, "unknown sessionId", req_id))
+ return
+ if not isinstance(prompt_blocks, list):
+ self.write_message(jsonrpc_error(-32602, "prompt must be an array", req_id))
+ return
+ prompt_text = content_blocks_to_text(prompt_blocks)
+ if not prompt_text:
+ self.write_message(jsonrpc_error(-32602, "prompt must contain text or supported content", req_id))
+ return
+
+ with session.prompt_lock:
+ if session.current_prompt_id is not None:
+ self.write_message(
+ jsonrpc_error(-32603, "session already has an active prompt", req_id)
+ )
+ return
+ session.current_prompt_id = req_id
+
+ def run_prompt() -> None:
+ stop_reason = "end_turn"
+ try:
+ dq = session.agent.put_task(prompt_text, source="acp")
+ self._drain_agent_queue(session, dq)
+ except Exception as exc:
+ stop_reason = "end_turn"
+ self.write_message(
+ make_session_update(
+ session.session_id,
+ {
+ "sessionUpdate": "agent_message_chunk",
+ "content": make_text_block(
+ f"[Bridge error] {type(exc).__name__}: {exc}"
+ ),
+ },
+ )
+ )
+ eprint("[GenericAgent ACP] prompt thread failed:", traceback.format_exc())
+ finally:
+ with session.prompt_lock:
+ finished_req_id = session.current_prompt_id
+ session.current_prompt_id = None
+ if finished_req_id is not None:
+ import time
+ time.sleep(0.1)
+ self.write_message(
+ jsonrpc_result(finished_req_id, {"stopReason": stop_reason})
+ )
+
+ threading.Thread(target=run_prompt, daemon=True).start()
+
+ def _drain_agent_queue(self, session: SessionState, dq: "queue.Queue[Dict[str, Any]]") -> None:
+ sent_any = False
+ while True:
+ item = dq.get()
+ if not isinstance(item, dict):
+ continue
+ # With inc_out=True, "next" items are already incremental deltas.
+ if "next" in item and "done" not in item:
+ delta = item["next"]
+ if isinstance(delta, str) and delta:
+ sent_any = True
+ try:
+ self.write_message(
+ make_session_update(
+ session.session_id,
+ {
+ "sessionUpdate": "agent_message_chunk",
+ "content": make_text_block(delta),
+ },
+ )
+ )
+ except Exception as e:
+ eprint(f"[ACP-BRIDGE] ERROR writing update: {e}")
+ if "done" in item:
+ # "done" text has post-processing ( \n\n insertion)
+ # that shifts offsets — cannot safely compute a tail delta.
+ # Only use "done" content if nothing was streamed (error case).
+ if not sent_any:
+ done_text = item["done"]
+ if isinstance(done_text, str) and done_text:
+ try:
+ self.write_message(
+ make_session_update(
+ session.session_id,
+ {
+ "sessionUpdate": "agent_message_chunk",
+ "content": make_text_block(done_text),
+ },
+ )
+ )
+ except Exception as e:
+ eprint(f"[ACP-BRIDGE] ERROR writing done: {e}")
+ break
+
+ def handle_session_cancel(self, params: Dict[str, Any]) -> None:
+ session_id = params.get("sessionId")
+ session = self._sessions.get(session_id)
+ if session is None:
+ return
+ if session.current_prompt_id is not None:
+ session.agent.abort()
+
+ def handle_message(self, msg: Dict[str, Any]) -> None:
+ method = msg.get("method")
+ req_id = msg.get("id")
+ params = msg.get("params") or {}
+
+ try:
+ if method == "initialize":
+ self.handle_initialize(req_id, params)
+ elif method == "session/new":
+ self.handle_session_new(req_id, params)
+ elif method == "session/prompt":
+ self.handle_session_prompt(req_id, params)
+ elif method == "session/cancel":
+ self.handle_session_cancel(params)
+ elif method == "session/load":
+ self.write_message(jsonrpc_error(-32601, "session/load not supported", req_id))
+ elif method == "session/list":
+ self.write_message(jsonrpc_error(-32601, "session/list not supported", req_id))
+ elif method == "session/close":
+ self.write_message(jsonrpc_result(req_id, {}))
+ elif method is None:
+ if req_id is not None:
+ self.write_message(jsonrpc_error(-32600, "invalid request", req_id))
+ else:
+ if req_id is not None:
+ self.write_message(jsonrpc_error(-32601, f"method not found: {method}", req_id))
+ except Exception as exc:
+ eprint("[GenericAgent ACP] request handler failed:", traceback.format_exc())
+ if req_id is not None:
+ self.write_message(
+ jsonrpc_error(-32603, f"internal error: {type(exc).__name__}: {exc}", req_id)
+ )
+
+ def serve(self) -> None:
+ eprint("[GenericAgent ACP] bridge started")
+ stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace") if hasattr(sys.stdin, 'buffer') else sys.stdin
+ for raw_line in stdin:
+ msg = parse_jsonrpc_line(raw_line)
+ if msg is None:
+ continue
+ self.handle_message(msg)
+ if self._shutdown:
+ break
+ eprint("[GenericAgent ACP] bridge stopped")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="GenericAgent ACP bridge over stdio")
+ parser.add_argument("--llm-no", type=int, default=0, help="LLM index for GenericAgent")
+ args = parser.parse_args()
+ bridge = GenericAgentAcpBridge(llm_no=args.llm_no)
+ bridge.serve()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/frontends/keysym.py b/frontends/keysym.py
new file mode 100644
index 000000000..3d931027e
--- /dev/null
+++ b/frontends/keysym.py
@@ -0,0 +1,72 @@
+"""Cross-platform keyboard-shortcut display formatter.
+
+One job: turn a Textual binding string like ``"ctrl+b"`` into a human-facing
+label such as ``"Ctrl+B"`` on Win/Linux or ``"⌃B"`` on macOS.
+
+Binding strings (the *physical* keys Textual captures) are NOT touched —
+this module only formats labels for tips / footers / help panels.
+
+Override with env ``GA_KEYSYM_STYLE=auto|mac|ascii`` (default ``auto``).
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+_STYLE = os.environ.get("GA_KEYSYM_STYLE", "auto").lower()
+IS_MAC = _STYLE == "mac" or (_STYLE != "ascii" and sys.platform == "darwin")
+
+# Modifier display per style. mac uses Apple HIG glyphs; others use words.
+_MOD = {
+ "ctrl": "⌃" if IS_MAC else "Ctrl",
+ "shift": "⇧" if IS_MAC else "Shift",
+ "alt": "⌥" if IS_MAC else "Alt",
+ "meta": "⌘" if IS_MAC else "Alt",
+ "super": "⌘" if IS_MAC else "Win",
+ "cmd": "⌘" if IS_MAC else "Win",
+}
+
+# Bare-key display. Arrows / slash are universal; rest mac-glyphs vs words.
+_KEY = {
+ "enter": "⏎" if IS_MAC else "Enter",
+ "tab": "⇥" if IS_MAC else "Tab",
+ "escape": "⎋" if IS_MAC else "Esc",
+ "esc": "⎋" if IS_MAC else "Esc",
+ "backspace": "⌫" if IS_MAC else "Backspace",
+ "delete": "⌦" if IS_MAC else "Del",
+ "space": "␣" if IS_MAC else "Space",
+ "up": "↑", "down": "↓", "left": "←", "right": "→",
+ "slash": "/", "underscore": "_",
+}
+
+# Joiner between modifier and key. mac concatenates (⌃B); others use '+'.
+_JOIN = "" if IS_MAC else "+"
+
+
+def fmt_key(combo: str) -> str:
+ """``"ctrl+b"`` → ``"⌃B"`` (mac) / ``"Ctrl+B"`` (Win/Linux).
+
+ Unknown single-char keys are upper-cased (``"b"`` → ``"B"``);
+ multi-char names fall back to the original token unchanged.
+ """
+ parts = [p.strip() for p in combo.lower().split("+") if p.strip()]
+ if not parts:
+ return combo
+ mods, key = parts[:-1], parts[-1]
+ key_disp = _KEY.get(key) or (key.upper() if len(key) == 1 else key)
+ mod_disp = [_MOD.get(m, m) for m in mods]
+ if not mod_disp:
+ return key_disp
+ return _JOIN.join(mod_disp) + _JOIN + key_disp
+
+
+def fmt_keys(*combos: str, sep: str = " / ") -> str:
+ """Join multiple combos: ``fmt_keys("ctrl+j", "ctrl+enter")`` →
+ ``"Ctrl+J / Ctrl+Enter"`` or ``"⌃J / ⌃⏎"``."""
+ return sep.join(fmt_key(c) for c in combos)
+
+
+# Convenience constants for f-string templates.
+CTRL = _MOD["ctrl"]
+SHIFT = _MOD["shift"]
+ALT = _MOD["alt"]
diff --git a/frontends/model_cmd.py b/frontends/model_cmd.py
new file mode 100644
index 000000000..5f9f098c4
--- /dev/null
+++ b/frontends/model_cmd.py
@@ -0,0 +1,120 @@
+"""/model 命令的 agent 无关逻辑: 拉模型列表 + 运行时改 model。
+被 tuiapp_v2 import; 不 patch 任何类, 不依赖 llmcore。"""
+from typing import Optional, List, Tuple
+
+import requests
+
+
+def _is_mixin(b) -> bool:
+ return type(b).__name__ == 'MixinSession'
+
+
+def _resolve(agent, sub: Optional[int] = None):
+ """返回 (真正持有 model 的 session, mixin或None)。"""
+ b = agent.llmclient.backend
+ if _is_mixin(b):
+ return b._sessions[b._cur_idx if sub is None else sub], b
+ return b, None
+
+
+def list_subsessions(agent) -> Optional[List[Tuple[int, str, bool]]]:
+ """mixin 渠道 → [(idx, name, is_current)]; 普通渠道 → None。"""
+ b = agent.llmclient.backend
+ if not _is_mixin(b):
+ return None
+ return [(i, s.name, i == b._cur_idx) for i, s in enumerate(b._sessions)]
+
+
+def current_model(agent, sub: Optional[int] = None) -> str:
+ s, _ = _resolve(agent, sub)
+ return s.model
+
+
+def fetch_models(agent, sub: Optional[int] = None, timeout: int = 10) -> List[str]:
+ """GET models 列表, 自动尝试 /models 与 /v1/models、原生头与 Bearer。"""
+ s, _ = _resolve(agent, sub)
+ base = s.api_base.rstrip('/')
+ urls = [f'{base}/models'] + ([] if base.endswith('/v1') else [f'{base}/v1/models'])
+ heads = [{'Authorization': f'Bearer {s.api_key}'}]
+ if 'Claude' in type(s).__name__:
+ heads.insert(0, {'x-api-key': s.api_key, 'anthropic-version': '2023-06-01'})
+ err = None
+ for url in urls:
+ for h in heads:
+ try:
+ r = requests.get(url, headers=h, timeout=timeout,
+ proxies=getattr(s, 'proxies', None),
+ verify=getattr(s, 'verify', True))
+ r.raise_for_status()
+ data = r.json().get('data', []) # 非 JSON(如 HTML 首页)会抛错进入下一候选
+ ids = {m['id'] for m in data if isinstance(m, dict) and m.get('id')}
+ if ids:
+ return sorted(ids)
+ except Exception as e:
+ err = e
+ raise err or RuntimeError('no models endpoint')
+
+
+def set_model(agent, model: str, sub: Optional[int] = None) -> str:
+ """运行时改 model(内存态, mykey 重载/重启后还原)。返回结果描述。"""
+ s, mixin = _resolve(agent, sub)
+ old = s.model
+ s.model = model # mixin 的 model 是只读 property, 必须落子 session
+ warn = ""
+ try: # 对齐 agentmain.next_llm 的中文 schema 切换
+ from agentmain import load_tool_schema
+ load_tool_schema('_cn' if any(x in model.lower() for x in ('glm', 'minimax', 'kimi')) else '')
+ except Exception as ex: # schema 选错会实际影响 agent 行为, 半径不为零, 不静默
+ warn = f" (⚠ schema 切换失败: {type(ex).__name__})"
+ where = f"[{s.name}]" if mixin else s.name
+ return f"{where}: {old} → {model}{warn}"
+
+
+# GA 配置层允许的全部档位(llmcore BaseSession._enum)。各协议的真实支持面不同:
+# Claude(output_config.effort) 只认 low/medium/high + xhigh→max, none/minimal
+# 会被 _apply_claude_thinking 打 WARN 忽略; OpenAI 系(reasoning_effort /
+# reasoning.effort) 原样透传, 由渠道端校验。
+EFFORT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
+
+
+def _protocols(agent) -> set:
+ """当前渠道涉及的协议集合 {'claude', 'openai'}。mixin 看全部子渠道(广播
+ 会落到每一个)。NativeOAISession 名字不含 'Claude', 正确归入 openai。"""
+ b = agent.llmclient.backend
+ sessions = b._sessions if _is_mixin(b) else [b]
+ return {('claude' if 'Claude' in type(s).__name__ else 'openai')
+ for s in sessions}
+
+
+def effort_note(level, protocols) -> str:
+ """单点描述某档位在给定协议上的特殊行为(空串=无特殊)。set_effort 的结果
+ 描述与 /effort picker 的行内备注共用它, 领域知识只在此编码一次。"""
+ if level and 'claude' in protocols:
+ if level in ('none', 'minimal'):
+ return 'Claude 渠道忽略'
+ if level == 'xhigh':
+ return 'Claude 对应 max'
+ return ''
+
+
+def current_effort(agent) -> str:
+ return getattr(agent.llmclient.backend, 'reasoning_effort', None) or ''
+
+
+def set_effort(agent, value) -> str:
+ """运行时改 reasoning_effort(内存态)。空值/off 清除(不再发送 effort 字段)。
+ 直接设在 backend 上: MixinSession 把它列为 _BROADCAST_ATTRS, 会同步到所有
+ 子渠道(故障切换后档位不丢); 普通渠道就是 session 本身。请求时各协议现读
+ 该属性, 立即生效。"""
+ e = (value or '').strip().lower()
+ if e in ('', 'off', 'clear', 'unset'):
+ e = None
+ elif e not in EFFORT_LEVELS:
+ return (f"无效 effort: {value!r}"
+ f" (可选 {'/'.join(EFFORT_LEVELS)}, 留空或 off 清除)")
+ b = agent.llmclient.backend
+ old = getattr(b, 'reasoning_effort', None)
+ b.reasoning_effort = e
+ note = effort_note(e, _protocols(agent))
+ tail = f" ({note})" if note else ""
+ return f"reasoning_effort: {old or '(未设置)'} → {e or '(清除)'}{tail}"
diff --git a/frontends/plan_state.py b/frontends/plan_state.py
new file mode 100644
index 000000000..cf5e865ef
--- /dev/null
+++ b/frontends/plan_state.py
@@ -0,0 +1,190 @@
+"""Plan / todo state — pure stdlib, no UI framework dependency.
+
+API:
+ extract(text) → [(content, "open"|"done"), …]
+ is_active(agent, messages=None) → plan mode on (stash OR per-session msg ref)
+ resolve_path(agent, messages=None) → live plan.md path (or None)
+ find_path_in_messages(messages) → most recent plan.md path mentioned
+ current_step(messages) → latest `当前步骤:…` snippet (or "")
+ summary(items) → (n_done, n_total)
+ is_complete(items) → all done (or empty)
+
+Supported task-line shapes (all matched by `extract`):
+ - [ ] foo ← bullet + open
+ - [x] foo ← bullet + done
+ 1. [✓] foo ← numbered + done
+ 2. [✓ 2026-05-16] foo ← numbered + timestamped done, content after bracket
+ 3. [✓ 已生成: foo] ← numbered + done with description *inside* bracket
+ 4. [D][P] foo ← two marker groups (delegate + parallel), still open
+ 5. [D] foo ← non-standard marker "D" → open (not done)
+"""
+from __future__ import annotations
+import os, re
+from typing import Optional
+
+_DONE_CHARS = set("xX✓✔√☑")
+# Newline-insert before a bullet stuck to JSON debris (`{"content": "- [ ] …`).
+_GLUE_RE = re.compile(r"(? str:
+ return _MD_EMPHASIS_RE.sub(lambda m: next(g for g in m.groups() if g is not None), s)
+
+
+def _has_done_glyph(marker: str) -> bool:
+ return any(c in _DONE_CHARS for c in marker)
+
+
+def extract(text: str) -> list[tuple[str, str]]:
+ if not text: return []
+ norm = text.replace("\\n", "\n") if "\\n" in text else text
+ norm = _GLUE_RE.sub(r"\n\1", norm)
+ found: dict[str, str] = {}
+ for line in norm.splitlines():
+ head = _BULLET_RE.match(line)
+ if not head: continue
+ rest = line[head.end():]
+ groups: list[str] = []
+ # Consume any number of consecutive `[...]` groups — covers `[D][P]`
+ # task-type chains as well as the plain `[ ]` / `[x]` single form.
+ while True:
+ b = _BRACKET_RE.match(rest)
+ if not b: break
+ groups.append(b.group(1))
+ rest = rest[b.end():]
+ if not groups: continue
+ is_done = any(_has_done_glyph(g) for g in groups)
+ inline = rest.strip()
+ if inline:
+ content = inline
+ elif is_done:
+ # `[✓ description]` shape — description lives inside the bracket
+ # next to the glyph. Strip the glyph + optional timestamp.
+ done_g = next(g for g in groups if _has_done_glyph(g))
+ content = _INLINE_STRIP_RE.sub("", done_g).strip()
+ else:
+ continue
+ k = _strip_md(_DEBRIS_RE.sub("", content).strip())
+ if not k: continue
+ status = "done" if is_done else "open"
+ # Same content seen twice — done wins over open.
+ if k not in found or status == "done":
+ found[k] = status
+ return list(found.items())
+
+
+def _stashed_plan_path(agent) -> str:
+ # First non-empty `working['in_plan_mode']` from (handler, agent).
+ for src in (getattr(agent, "handler", None), agent):
+ p = ((getattr(src, "working", None) or {}).get("in_plan_mode") or "").strip()
+ if p: return p
+ return ""
+
+
+def _resolve_stashed(p: str) -> Optional[str]:
+ if not p: return None
+ rel = p.lstrip("./\\")
+ cwd = os.getcwd()
+ for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)):
+ if os.path.isfile(c) and os.path.getsize(c) > 0: return c
+ return None
+
+
+# Strict per-session discovery — scan this session's own messages only.
+_PATH_RE = re.compile(r"""((?:\.\/)?(?:temp\/)?plan_[A-Za-z0-9_\-]+\/plan\.md)""")
+
+
+def _slice(messages, start_idx: int):
+ if not messages: return []
+ if start_idx <= 0: return list(messages)
+ return list(messages)[start_idx:]
+
+
+def find_path_in_messages(messages, start_idx: int = 0) -> Optional[str]:
+ """Latest existing `plan_XXX/plan.md` referenced after `start_idx`.
+ Items can be `ChatMessage`-like (`.content`) or plain strings;
+ only paths that exist on disk are returned."""
+ sliced = _slice(messages, start_idx)
+ if not sliced: return None
+ for m in reversed(sliced):
+ text = getattr(m, "content", None)
+ if text is None: text = m if isinstance(m, str) else ""
+ if not text or "plan.md" not in text: continue
+ for hit in reversed(_PATH_RE.findall(text)):
+ p = _resolve_stashed(hit.strip().strip("\"'"))
+ if p: return p
+ return None
+
+
+# Prefer concise `` narrative over the long plan-item echo;
+# treat `❌ 当前步骤:` as "step done", not "current step".
+_SUMMARY_STEP_RE = re.compile(
+ r"[^<]*?当前步骤[::]\s*([^<\n]{1,160}) ", re.DOTALL)
+_STEP_RE = re.compile(r"📌\s*当前步骤[::]\s*([^\n。!!??]{1,160})")
+_DONE_STEP_RE = re.compile(r"❌\s*当前步骤[::]")
+
+
+def current_step(messages, start_idx: int = 0, max_len: int = 60) -> str:
+ """Latest `当前步骤:…` snippet; `` form preferred, `❌`-prefixed
+ skipped. Trimmed to `max_len` chars so it fits the 5-row plan card."""
+ sliced = _slice(messages, start_idx)
+ if not sliced: return ""
+
+ def _clean(s: str) -> str:
+ return _strip_md(re.sub(r"\s+", " ", s).strip().rstrip(" ::—-"))
+
+ def _cap(s: str) -> str:
+ s = _clean(s)
+ if len(s) <= max_len: return s
+ return s[:max_len - 1].rstrip() + "…"
+
+ for m in reversed(sliced):
+ text = getattr(m, "content", None)
+ if text is None: text = m if isinstance(m, str) else ""
+ if not text or "当前步骤" not in text: continue
+ hits = _SUMMARY_STEP_RE.findall(text)
+ if hits: return _cap(hits[-1])
+ for raw in reversed(_STEP_RE.findall(text)):
+ if _DONE_STEP_RE.search(raw): continue
+ return _cap(raw)
+ return ""
+
+
+def is_active(agent, messages=None, start_idx: int = 0,
+ restored_path: str = "") -> bool:
+ """Plan mode is on. Primary: `working['in_plan_mode']`. Then
+ `restored_path` — a path recovered from the transcript's structured
+ `enter_plan_mode` tool_use by /continue (see continue_cmd.find_plan_entry);
+ unlike the message scan it cannot be spoofed by a path typed in chat.
+ Legacy fallback: a `plan_*/plan.md` referenced in this session's messages
+ (no global scan) — only consulted when `messages` is passed."""
+ if _stashed_plan_path(agent): return True
+ if restored_path and _resolve_stashed(restored_path): return True
+ return find_path_in_messages(messages, start_idx) is not None
+
+
+def resolve_path(agent, messages=None, start_idx: int = 0,
+ restored_path: str = "") -> Optional[str]:
+ p = _resolve_stashed(_stashed_plan_path(agent))
+ if p: return p
+ if restored_path:
+ p = _resolve_stashed(restored_path)
+ if p: return p
+ return find_path_in_messages(messages, start_idx)
+
+
+def summary(items: list[tuple[str, str]]) -> tuple[int, int]:
+ return sum(1 for _, st in items if st == "done"), len(items)
+
+
+def is_complete(items: list[tuple[str, str]]) -> bool:
+ return not items or all(st == "done" for _, st in items)
diff --git a/frontends/qqapp.py b/frontends/qqapp.py
new file mode 100644
index 000000000..2ad088f88
--- /dev/null
+++ b/frontends/qqapp.py
@@ -0,0 +1,130 @@
+import asyncio, os, sys, threading, time
+from collections import deque
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from agentmain import GeneraticAgent
+from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
+from llmcore import mykeys
+
+try:
+ import botpy
+ from botpy.message import C2CMessage, GroupMessage
+except Exception:
+ print("Please install qq-botpy to use QQ module: pip install qq-botpy")
+ sys.exit(1)
+
+agent = GeneraticAgent(); agent.verbose = False
+APP_ID = str(mykeys.get("qq_app_id", "") or "").strip()
+APP_SECRET = str(mykeys.get("qq_app_secret", "") or "").strip()
+ALLOWED = {str(x).strip() for x in mykeys.get("qq_allowed_users", []) if str(x).strip()}
+PROCESSED_IDS, USER_TASKS = deque(maxlen=1000), {}
+SEQ_LOCK, MSG_SEQ = threading.Lock(), 1
+
+
+def _next_msg_seq():
+ global MSG_SEQ
+ with SEQ_LOCK:
+ MSG_SEQ += 1
+ return MSG_SEQ
+
+
+def _build_intents():
+ try:
+ return botpy.Intents(public_messages=True, direct_message=True)
+ except Exception:
+ intents = botpy.Intents.none() if hasattr(botpy.Intents, "none") else botpy.Intents()
+ for attr in ("public_messages", "public_guild_messages", "direct_message", "direct_messages", "c2c_message", "c2c_messages", "group_at_message", "group_at_messages"):
+ if hasattr(intents, attr):
+ try:
+ setattr(intents, attr, True)
+ except Exception:
+ pass
+ return intents
+
+
+def _make_bot_class(app):
+ class QQBot(botpy.Client):
+ def __init__(self):
+ super().__init__(intents=_build_intents(), ext_handlers=False)
+
+ async def on_ready(self):
+ print(f"[QQ] bot ready: {getattr(getattr(self, 'robot', None), 'name', 'QQBot')}")
+
+ async def on_c2c_message_create(self, message: C2CMessage):
+ await app.on_message(message, is_group=False)
+
+ async def on_group_at_message_create(self, message: GroupMessage):
+ await app.on_message(message, is_group=True)
+
+ async def on_direct_message_create(self, message):
+ await app.on_message(message, is_group=False)
+
+ return QQBot
+
+
+class QQApp(AgentChatMixin):
+ label, source, split_limit = "QQ", "qq", 1500
+
+ def __init__(self):
+ super().__init__(agent, USER_TASKS)
+ self.client = None
+
+ async def send_text(self, chat_id, content, *, msg_id=None, is_group=False):
+ if not self.client:
+ return
+ api = self.client.api.post_group_message if is_group else self.client.api.post_c2c_message
+ key = "group_openid" if is_group else "openid"
+ for part in split_text(content, self.split_limit):
+ seq = _next_msg_seq()
+ try:
+ await api(**{key: chat_id, "msg_type": 2, "markdown": {"content": part}, "msg_id": msg_id, "msg_seq": seq})
+ except Exception:
+ await api(**{key: chat_id, "msg_type": 0, "content": part, "msg_id": msg_id, "msg_seq": seq})
+
+ async def on_message(self, data, is_group=False):
+ try:
+ msg_id = getattr(data, "id", None)
+ if msg_id in PROCESSED_IDS:
+ return
+ PROCESSED_IDS.append(msg_id)
+ content = (getattr(data, "content", "") or "").strip()
+ if not content:
+ return
+ author = getattr(data, "author", None)
+ user_id = str(getattr(author, "member_openid" if is_group else "user_openid", "") or getattr(author, "id", "") or "unknown")
+ chat_id = str(getattr(data, "group_openid", "") or user_id) if is_group else user_id
+ if not public_access(ALLOWED) and user_id not in ALLOWED:
+ print(f"[QQ] unauthorized user: {user_id}")
+ return
+ print(f"[QQ] message from {user_id} ({'group' if is_group else 'c2c'}): {content}")
+ if content.startswith("/"):
+ return await self.handle_command(chat_id, content, msg_id=msg_id, is_group=is_group)
+ asyncio.create_task(self.run_agent(chat_id, content, msg_id=msg_id, is_group=is_group))
+ except Exception:
+ import traceback
+ print("[QQ] handle_message error")
+ traceback.print_exc()
+
+ async def start(self):
+ self.client = _make_bot_class(self)()
+ delay, max_delay = 5, 300
+ while True:
+ started_at = time.monotonic()
+ try:
+ print(f"[QQ] bot starting... {time.strftime('%m-%d %H:%M')}")
+ await self.client.start(appid=APP_ID, secret=APP_SECRET)
+ except Exception as e:
+ print(f"[QQ] bot error: {e}")
+ if time.monotonic() - started_at >= 60:
+ delay = 5
+ print(f"[QQ] reconnect in {delay}s...")
+ await asyncio.sleep(delay)
+ delay = min(delay * 2, max_delay)
+
+
+if __name__ == "__main__":
+ _LOCK_SOCK = ensure_single_instance(19528, "QQ")
+ require_runtime(agent, "QQ", qq_app_id=APP_ID, qq_app_secret=APP_SECRET)
+ redirect_log(__file__, "qqapp.log", "QQ", ALLOWED)
+ threading.Thread(target=agent.run, daemon=True).start()
+ asyncio.run(QQApp().start())
diff --git a/frontends/qtapp.py b/frontends/qtapp.py
new file mode 100644
index 000000000..5e40a9c3f
--- /dev/null
+++ b/frontends/qtapp.py
@@ -0,0 +1,2478 @@
+"""
+桌面前端单文件版 – PySide6 聊天面板 + 悬浮按钮 thanks to GaoZhiCheng
+依赖: pip install PySide6
+可选: pip install markdown (Markdown 渲染)
+用法: python frontends/qtapp.py
+"""
+from __future__ import annotations
+
+import math, os, sys, json, glob, re, base64, time, threading
+import queue as _queue
+from datetime import datetime
+from typing import Optional
+
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+ QScrollArea, QFrame, QTextEdit, QStackedWidget,
+ QListWidget, QListWidgetItem, QSizePolicy, QFileDialog,
+ QSplitter, QTextBrowser, QApplication, QMessageBox,
+ QMenu, QLineEdit,
+)
+from PySide6.QtCore import (
+ Qt, QTimer, QPoint, QPointF, QByteArray, QSize,
+ Signal, QMetaObject, Q_ARG, QObject, QDateTime, QEvent,
+)
+from PySide6.QtGui import (
+ QPainter, QColor, QLinearGradient, QRadialGradient,
+ QPen, QPainterPath, QCursor, QFont, QIcon, QPixmap, QRegion,
+)
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+from agentmain import GeneraticAgent
+from chatapp_common import FILE_HINT, HELP_TEXT, clean_reply, build_done_text, format_restore
+
+
+# ══════════════════════════════════════════════════════════════════════
+# FloatingButton
+# ══════════════════════════════════════════════════════════════════════
+
+class FloatingButton(QWidget):
+ SIZE = 60 # circle diameter
+ MARGIN = 14 # extra space for glow
+ TOTAL = SIZE + MARGIN * 2
+
+ def __init__(self, chat_panel: QWidget):
+ super().__init__()
+ self.chat_panel = chat_panel
+ self._drag_origin_global: QPoint | None = None
+ self._drag_origin_win: QPoint | None = None
+ self._dragged = False
+ self._glow = 0.5
+ self._glow_dir = 1
+ self._hovering = False
+ self._hover_clock = 0.0
+ self._hover_strength = 0.0
+ self._flow_phase = 0.0
+ self._running = False
+ self._last_toggle_ms = 0 # debounce timestamp
+
+ # Window flags: frameless, always on top, no taskbar entry
+ self.setWindowFlags(
+ Qt.FramelessWindowHint
+ | Qt.WindowStaysOnTopHint
+ | Qt.Tool
+ )
+ self.setAttribute(Qt.WA_TranslucentBackground)
+ self.setFixedSize(self.TOTAL, self.TOTAL)
+ self.setCursor(QCursor(Qt.PointingHandCursor))
+
+ # Smooth animation (~30 fps)
+ self._timer = QTimer(self)
+ self._timer.timeout.connect(self._tick)
+ self._timer.start(33)
+
+ # Default position: bottom-right of the work area
+ scr = QApplication.primaryScreen().availableGeometry()
+ self.move(scr.right() - self.TOTAL - 20, scr.bottom() - self.TOTAL - 20)
+
+ # ── Animation ────────────────────────────────────────
+ def _tick(self):
+ # running status: green when model is actively responding
+ self._running = bool(
+ getattr(self.chat_panel, "_is_streaming", False)
+ or getattr(getattr(self.chat_panel, "agent", None), "is_running", False)
+ )
+
+ self._glow += self._glow_dir * 0.04
+ if self._glow >= 1.0:
+ self._glow, self._glow_dir = 1.0, -1
+ elif self._glow <= 0.0:
+ self._glow, self._glow_dir = 0.0, 1
+
+ target = 1.0 if self._hovering else 0.0
+ self._hover_strength += (target - self._hover_strength) * 0.20
+ self._hover_clock += 0.033
+ self._flow_phase += 0.16 + (0.06 if self._running else 0.0) + (0.05 if self._hovering else 0.0)
+ self.update()
+
+ # ── Painting ──────────────────────────────────────────
+ def paintEvent(self, _event):
+ p = QPainter(self)
+ p.setRenderHint(QPainter.Antialiasing)
+
+ m = self.MARGIN
+ r = self.SIZE // 2
+ cx = m + r
+ # Rhythmic spring bounce: one main hop + one lighter rebound per beat.
+ beat_t = self._hover_clock % 1.18
+ spring = 0.0
+ if beat_t < 0.70:
+ spring += max(0.0, math.exp(-5.2 * beat_t) * math.sin(15.5 * beat_t))
+ if beat_t > 0.20:
+ rt = beat_t - 0.20
+ spring += 0.52 * max(0.0, math.exp(-7.0 * rt) * math.sin(21.0 * rt))
+ idle_sway = 0.20 * math.sin(self._hover_clock * 2.1)
+ bounce = int(round((spring * 7.2 + idle_sway) * self._hover_strength))
+ cy = m + r - bounce
+
+ if self._running:
+ # running: #2DFFF5 -> #FFF878
+ g0 = QColor(45, 255, 245, 195)
+ g1 = QColor(255, 248, 120, 195)
+ glow_rgb = (96, 255, 216)
+ else:
+ # idle: #103CE7 -> #64E9FF
+ g0 = QColor(16, 60, 231, 195)
+ g1 = QColor(100, 233, 255, 195)
+ glow_rgb = (74, 170, 255)
+
+ # --- Outer glow rings (3 layers) ---
+ base_alpha = int(45 + 25 * self._glow)
+ for i, gr in enumerate([r + 10, r + 6, r + 2]):
+ g = QRadialGradient(QPointF(cx, cy), gr)
+ g.setColorAt(0.0, QColor(glow_rgb[0], glow_rgb[1], glow_rgb[2], max(0, base_alpha - i * 14)))
+ g.setColorAt(1.0, QColor(glow_rgb[0], glow_rgb[1], glow_rgb[2], 0))
+ p.setBrush(g)
+ p.setPen(Qt.NoPen)
+ p.drawEllipse(int(cx - gr), int(cy - gr), int(gr * 2), int(gr * 2))
+
+ # --- Frosted glass disc behind main circle ---
+ frost = QRadialGradient(QPointF(cx, cy), r)
+ frost.setColorAt(0.0, QColor(30, 30, 45, 140))
+ frost.setColorAt(0.85, QColor(20, 20, 32, 160))
+ frost.setColorAt(1.0, QColor(14, 14, 20, 100))
+ p.setBrush(frost)
+ p.setPen(Qt.NoPen)
+ p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
+
+ # --- Main circle (flowing state gradient) ---
+ spin = self._flow_phase
+ dx = math.cos(spin) * r
+ dy = math.sin(spin) * r
+ grad = QLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy)
+ grad.setColorAt(0.0, g0)
+ grad.setColorAt(1.0, g1)
+ p.setBrush(grad)
+ p.setPen(QPen(QColor(255, 255, 255, 50), 1.5))
+ p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
+
+ # --- Flowing glass streaks ---
+ clip = QPainterPath()
+ clip.addEllipse(float(cx - r), float(cy - r), float(r * 2), float(r * 2))
+ p.setClipPath(clip)
+
+ flow_shift = math.sin(self._flow_phase * 0.85) * (r * 0.7)
+ streak1 = QLinearGradient(cx - r + flow_shift, cy - r, cx + r + flow_shift, cy + r)
+ streak1.setColorAt(0.00, QColor(255, 255, 255, 0))
+ streak1.setColorAt(0.45, QColor(255, 255, 255, 42))
+ streak1.setColorAt(0.52, QColor(255, 255, 255, 78))
+ streak1.setColorAt(0.60, QColor(255, 255, 255, 24))
+ streak1.setColorAt(1.00, QColor(255, 255, 255, 0))
+ p.setBrush(streak1)
+ p.setPen(Qt.NoPen)
+ p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
+
+ flow_shift_2 = math.cos(self._flow_phase * 1.2) * (r * 0.5)
+ streak2 = QLinearGradient(cx - r, cy + flow_shift_2, cx + r, cy - flow_shift_2)
+ streak2.setColorAt(0.00, QColor(255, 255, 255, 0))
+ streak2.setColorAt(0.35, QColor(255, 255, 255, 16))
+ streak2.setColorAt(0.50, QColor(255, 255, 255, 46))
+ streak2.setColorAt(0.65, QColor(255, 255, 255, 16))
+ streak2.setColorAt(1.00, QColor(255, 255, 255, 0))
+ p.setBrush(streak2)
+ p.drawEllipse(cx - r, cy - r, r * 2, r * 2)
+
+ # --- Top highlight ---
+ hl = QLinearGradient(cx, cy - r, cx, cy)
+ hl.setColorAt(0.0, QColor(255, 255, 255, 72))
+ hl.setColorAt(1.0, QColor(255, 255, 255, 0))
+ p.setBrush(hl)
+ p.drawRect(cx - r, cy - r, r * 2, r)
+ p.setClipping(False)
+
+ # --- Bot icon ---
+ p.setPen(QPen(QColor(255, 255, 255, 220), 1.8))
+ p.setBrush(Qt.NoBrush)
+ # Head
+ p.drawRoundedRect(cx - 9, cy - 6, 18, 12, 2, 2)
+ # Eyes
+ p.setBrush(QColor(255, 255, 255, 220))
+ p.setPen(Qt.NoPen)
+ p.drawEllipse(cx - 6, cy - 3, 4, 4)
+ p.drawEllipse(cx + 2, cy - 3, 4, 4)
+ # Antenna stem
+ p.setPen(QPen(QColor(255, 255, 255, 220), 1.8))
+ p.drawLine(cx, cy - 6, cx, cy - 10)
+ # Antenna tip
+ p.setBrush(QColor(255, 255, 255, 190))
+ p.setPen(Qt.NoPen)
+ p.drawEllipse(cx - 2, cy - 13, 4, 4)
+
+ def enterEvent(self, event):
+ self._hovering = True
+ self.update()
+ super().enterEvent(event)
+
+ def leaveEvent(self, event):
+ self._hovering = False
+ self.update()
+ super().leaveEvent(event)
+
+ # ── Mouse events (drag + click) ───────────────────────
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self._drag_origin_global = event.globalPosition().toPoint()
+ self._drag_origin_win = self.pos()
+ self._dragged = False
+
+ def mouseMoveEvent(self, event):
+ if event.buttons() == Qt.LeftButton and self._drag_origin_global:
+ delta = event.globalPosition().toPoint() - self._drag_origin_global
+ if abs(delta.x()) > 5 or abs(delta.y()) > 5:
+ self._dragged = True
+ if self._dragged:
+ new = self._drag_origin_win + delta
+ scr = QApplication.primaryScreen().availableGeometry()
+ new.setX(max(scr.left(), min(new.x(), scr.right() - self.width())))
+ new.setY(max(scr.top(), min(new.y(), scr.bottom() - self.height())))
+ self.move(new)
+
+ def mouseDoubleClickEvent(self, event):
+ # Qt sends Press→Release→DoubleClick→Release on double-click.
+ # The first Release already toggled the panel; swallow the DoubleClick
+ # so the second Release does NOT trigger a second toggle.
+ self._dragged = True # mark as "dragged" → Release will be ignored
+ event.accept()
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ if not self._dragged:
+ self._toggle()
+ self._dragged = False
+ self._drag_origin_global = None
+
+ # ── Toggle panel ──────────────────────────────────────
+ def _toggle(self):
+ now = QDateTime.currentMSecsSinceEpoch()
+ if now - self._last_toggle_ms < 500: # 500 ms debounce
+ return
+ self._last_toggle_ms = now
+
+ if self.chat_panel.isVisible():
+ self.chat_panel.hide()
+ else:
+ self._position_panel()
+ self.chat_panel.show()
+ self.chat_panel.raise_()
+ self.chat_panel.activateWindow()
+
+ def _position_panel(self):
+ scr = QApplication.primaryScreen().availableGeometry()
+ btn = self.geometry()
+ pw = self.chat_panel.width()
+ ph = self.chat_panel.height()
+ # Prefer left of button, bottom-aligned
+ x = btn.left() - pw - 12
+ y = btn.bottom() - ph
+ x = max(scr.left() + 10, min(x, scr.right() - pw - 10))
+ y = max(scr.top() + 10, min(y, scr.bottom() - ph - 10))
+ self.chat_panel.move(x, y)
+
+
+# ══════════════════════════════════════════════════════════════════════
+# ChatPanel
+# ══════════════════════════════════════════════════════════════════════
+
+# ── constants ─────────────────────────────────────────────────────────────────
+HISTORY_FILE = "memory/chat_history.json"
+TEXT_FILE_EXTS = {
+ ".txt", ".md", ".py", ".json", ".csv", ".yaml", ".yml",
+ ".log", ".ini", ".toml", ".xml", ".html", ".js", ".ts", ".sql",
+}
+MAX_INLINE_CHARS = 6000
+MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
+AUTO_IDLE_THRESHOLD = 1800 # seconds before autonomous trigger
+AUTO_COOLDOWN = 120 # seconds between triggers
+
+C = {
+ "bg": QColor(14, 14, 18),
+ "panel": QColor(20, 20, 24, 248),
+ "border": QColor(45, 45, 50),
+ "accent": "#7c3aed",
+ "text": "#e4e4e7",
+ "muted": "#71717a",
+ "user_g0": QColor(79, 70, 229),
+ "user_g1": QColor(124, 58, 237),
+ "asst_bg": QColor(39, 39, 42, 210),
+ "asst_bdr": QColor(63, 63, 70),
+ "send_g0": QColor(220, 38, 38),
+ "send_g1": QColor(239, 68, 68),
+ "green": "#22c55e",
+ "hover_bg": "rgba(63,63,70,0.6)",
+ "accent_bg":"rgba(124,58,237,0.25)",
+ "accent_bdr":"rgba(124,58,237,0.5)",
+}
+
+SCROLLBAR_STYLE = """
+QScrollBar:vertical { width: 5px; background: transparent; border: none; }
+QScrollBar::handle:vertical {
+ background: rgba(255,255,255,0.12); border-radius: 2px; min-height: 20px;
+}
+QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
+QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; }
+"""
+
+_SVG_COPY = ' '
+_SVG_REGEN = ' '
+_SVG_CHAT = ' '
+_SVG_CLOCK = ' '
+_SVG_SEARCH = ' '
+_SVG_BOOK = ' '
+_SVG_GEAR = ' '
+_SVG_PLUS = ' '
+_SVG_CLIP = _SVG_PLUS
+_SVG_STOP = ' '
+_SVG_RESET = _SVG_REGEN
+_SVG_SAVE = ' '
+_SVG_TRASH = ' '
+_SVG_BOLT = ' '
+_SVG_PLAY = ' '
+_SVG_FILE = ' '
+_SVG_USER = ' '
+_SVG_BOT = ' '
+_SVG_SEND = ' '
+
+_MD_CSS = """
+body { color: #e4e4e7; font-family: "Arial", "Microsoft YaHei", sans-serif; font-size: 13px; line-height: 1.6; font-weight: 400; }
+h1 { color: #f4f4f5; font-size: 20px; font-weight: 700; border-bottom: 1px solid #3f3f46; padding-bottom: 4px; margin-top: 16px; }
+h2 { color: #f4f4f5; font-size: 17px; font-weight: 700; border-bottom: 1px solid #3f3f46; padding-bottom: 3px; margin-top: 14px; }
+h3 { color: #f4f4f5; font-size: 15px; font-weight: 600; margin-top: 12px; }
+h4,h5,h6 { color: #d4d4d8; font-size: 13px; font-weight: 600; margin-top: 10px; }
+code { background: rgba(63,63,70,0.6); color: #c4b5fd; padding: 1px 4px; border-radius: 3px;
+ font-family: Consolas, "Courier New", monospace; font-size: 12px; }
+pre { background: rgba(24,24,30,0.95); border: 1px solid #3f3f46; border-radius: 6px;
+ padding: 10px 12px; margin: 8px 0; }
+pre code { background: transparent; padding: 0; color: #d4d4d8; }
+a { color: #818cf8; text-decoration: none; }
+a:hover { text-decoration: underline; }
+blockquote { border-left: 3px solid #7c3aed; margin: 8px 0 8px 0; padding: 4px 0 4px 12px; color: #a1a1aa; }
+table { border-collapse: collapse; margin: 8px 0; }
+th, td { border: 1px solid #3f3f46; padding: 5px 10px; }
+th { background: rgba(63,63,70,0.35); color: #d4d4d8; font-weight: 700; }
+hr { border: none; border-top: 1px solid #3f3f46; margin: 12px 0; }
+ul, ol { padding-left: 22px; margin: 4px 0; }
+li { margin: 2px 0; }
+p { margin: 6px 0; }
+"""
+
+
+def _md_to_html(text: str) -> str:
+ try:
+ import markdown
+ return markdown.markdown(
+ text, extensions=["fenced_code", "tables", "nl2br", "sane_lists"]
+ )
+ except ImportError:
+ pass
+ html, in_code, in_ul = [], False, False
+ for raw in text.split("\n"):
+ if raw.strip().startswith("```"):
+ if in_code:
+ html.append("")
+ 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 000000000..b0b982591
--- /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 000000000..adc1734eb
--- /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 000000000..506622db7
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 000000000..9a116cb44
--- /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 000000000..02413f374
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 000000000..ee2f5b0ea
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 000000000..c961b0eac
--- /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 000000000..60a596a6f
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 000000000..b4b17cc05
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 000000000..f084745e6
--- /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 000000000..8fb87343a
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 000000000..b2905cbaa
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 000000000..28fe80117
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 000000000..636ab8d3e
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 000000000..2815e2627
--- /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 000000000..636ab8d3e
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 000000000..e0ec332b1
--- /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 000000000..442116788
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 000000000..09d1fdc06
--- /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 000000000..41b102e80
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 000000000..1b4dcbeff
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 000000000..f3a031c9f
--- /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 000000000..908992378
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 000000000..12c9aecca
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 000000000..92c630421
--- /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 000000000..c99f59ecd
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 000000000..005dff51b
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 000000000..0978d205e
--- /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 000000000..1ee674433
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 000000000..dfd778ef1
--- /dev/null
+++ b/frontends/slash_cmds.py
@@ -0,0 +1,588 @@
+"""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
+
+
+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. Fetch upstream; identify the current branch and whether there are any local "
+ "commits ahead of upstream.\n"
+ "2. Show a concise preview: recent upstream commits not yet local "
+ "(short hash + subject + date) plus a changed-files summary.\n"
+ "3. Apply the update (uncommitted working-tree changes must always be preserved):\n"
+ " - If local commits exist: merge upstream into the current branch, auto-resolving "
+ "conflicts in favor of upstream, and keep the local commits. A merge commit is acceptable here.\n"
+ " - Otherwise: run `git reset --mixed upstream/main` so local `main` aligns exactly "
+ "with upstream/main, automatically resolving conflicts in favor of upstream. Keep the local changes but do not create any new commits.\n"
+ "4. Finish with a short summary: branch HEAD, changed files, conflicts/resolutions.\n"
+ "\n"
+ "#### Say 'Congratulations! 🎉' to the user\n"
+ "After a successful update, say: \"Congratulations! 🎉 You have successfully "
+ "updated GenericAgent!\"\n"
+ "\n"
+ "#### Ask for a Star ⭐\n"
+ "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. 先 fetch upstream;识别当前分支,以及是否存在领先 upstream 的本地 commit。\n"
+ "2. 给出简洁预览:本地尚未包含的 upstream 最近提交(短 hash + 标题 + 日期)及变更文件摘要。\n"
+ "3. 执行更新(工作区未提交的改动始终要保留):\n"
+ " - 若存在本地 commit:将 upstream 合并进当前分支,自动解决冲突且优先上游。\n"
+ " - 否则:执行 `git reset --mixed upstream/main`,使本地 `main` 与 upstream/main 完全对齐,自动解决冲突且优先上游。本地修改保留但不要进行commit。\n"
+ "4. 最后给出简短小结:分支 HEAD、变更文件、冲突/解决方式。\n"
+ "\n"
+ "#### 向用户说 'Congratulations! 🎉'\n"
+ "更新成功后,请对用户说:\"Congratulations! 🎉 你已成功更新 GenericAgent!\"\n"
+ "\n"
+ "#### 邀请用户点 Star ⭐\n"
+ "随后可邀请:\"如果觉得有帮助,要不要给 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 000000000..b15f1ed65
--- /dev/null
+++ b/frontends/stapp.py
@@ -0,0 +1,338 @@
+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 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': '强行停止任务',
+ 'reinject_tools': '重新注入工具',
+ 'desktop_pet': '🐱 桌面宠物',
+ },
+ 'en': {
+ 'force_stop': 'Force Stop',
+ 'reinject_tools': 'Reinject Tools',
+ 'desktop_pet': '🐱 Desktop Pet',
+ },
+}
+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()
+
+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('reinject_tools')):
+ agent.llmclient.last_tools = ''
+ try:
+ hist_path = os.path.join(script_dir, '..', 'assets', 'tool_usable_history.json')
+ with open(hist_path, 'r', encoding='utf-8') as f: tool_hist = json.load(f)
+ agent.llmclient.backend.history.extend(tool_hist)
+ st.toast(f"Tools injected")
+ except Exception as e: st.toast(f"Injected tools failed: {e}")
+ 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 LANG == 'zh':
+ if st.button('🎯 给我找点事做'):
+ st.session_state['_inject_prompt'] = '按照自主行动的规划部分,充分分析我的情况,给我生成一批TODO,务必让我感兴趣'
+ st.rerun(scope="app")
+ st.divider()
+ if st.button("开始空闲自主行动"):
+ st.session_state.last_reply_time = int(time.time()) - 1800
+ st.session_state.autonomous_enabled = True
+ st.toast("已将上次回复时间设为1800秒前,自主行动已激活"); st.rerun(scope="app")
+ if st.session_state.autonomous_enabled:
+ if st.button("⏸️ 禁止自主行动"):
+ st.session_state.autonomous_enabled = False
+ st.toast("⏸️ 已禁止自主行动"); st.rerun(scope="app")
+ st.caption("🟢 自主行动运行中,会在你离开它30分钟后自动进行")
+ else:
+ if st.button("▶️ 允许自主行动", type="primary"):
+ st.session_state.autonomous_enabled = True
+ st.toast("✅ 已允许自主行动"); st.rerun(scope="app")
+ st.caption("🔴 自主行动已停止")
+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())
+
+if "messages" not in st.session_state: st.session_state.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()
+
+if st.session_state.autonomous_enabled:
+ st.markdown(f"""{st.session_state.get('last_reply_time', int(time.time()))}
""", unsafe_allow_html=True)
diff --git a/frontends/stapp2.py b/frontends/stapp2.py
new file mode 100644
index 000000000..1d7968f5d
--- /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 000000000..ea3b3a1a9
--- /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 000000000..47fd61232
--- /dev/null
+++ b/frontends/tui_v3.py
@@ -0,0 +1,6018 @@
+"""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'
+ if has('home'):
+ return b'\x01'
+ if has('end'):
+ return b'\x05'
+ 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)>.*?(?: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
+ # 默认普通模式:设 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 '?'
+
+ 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)
+_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\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 (