diff --git a/.gitignore b/.gitignore index c8ca6bf0d..a16c486ef 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..f5958903d 100644 --- a/README.md +++ b/README.md @@ -1,281 +1,794 @@ -# GenericAgent — 3,300 Lines to Full OS Autonomy +
-[English](#english) | [中文](#chinese) +GenericAgent Banner - +# 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.
+ Technical Report + Reproduction Repo + Tutorial + Sophub +

+ +

+ Trendshift +

+ +**[English](#-english) · [中文](#-中文)** + +
+ +> 📌 **Official Channel** — This GitHub repository is the **only** official source of GenericAgent. +> We have no affiliation with any third-party website using the GenericAgent name. + +--- + + + +## 🌟 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) +- [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** | 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.
🧋 Food Delivery Order📈 Quantitative Stock Screening
Order TeaStock Selection
"Order me a milk tea" — navigates the delivery app, selects items, completes checkout."Find GEM stocks with EXPMA golden cross, turnover > 5%" — quantitative screening.
🌐 Autonomous Web Exploration💰 Expense Tracking
Web ExplorationAlipay Expense
Autonomously browses and periodically summarizes web content."Find expenses over ¥2K in the last 3 months" — drives Alipay via ADB.
💬 Batch Messaging
WeChat Batch
Sends bulk WeChat messages, fully driving the WeChat client.
-## What Happens When You Use It +--- -``` -You: "Read my WeChat messages" -Agent: installs dependencies → reverse-engineers DB → writes reader script → saves as SOP -Next time: instant recall, zero setup. +## 🚀 Quick Start -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. +> ⚠️ **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)** -You: "Send this file via Gmail" -Agent: configures OAuth → writes send script → saves as SOP -Next time: just works. -``` +### For LLM Agents -**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. +Fetch the installation guide and follow it: -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. +```bash +curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/main/docs/installation.md +``` -## The Seed Philosophy +### For Humans -Most agent frameworks ship as finished products. GenericAgent ships as a **seed**. +#### Method 1 — One-line install *(recommended)* -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: +This installs GenericAgent with an isolated Python environment and Git, then downloads a ready-to-run package. -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 http://fudankw.cn:9000/files/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 http://fudankw.cn:9000/files/ga_install.sh)" +``` -# 2. Install minimal deps -pip install streamlit pywebview +After installation, launch the desktop app from: -# 3. Configure API key -cp mykey_template.py mykey.py -# Edit mykey.py with your LLM API key +```text +frontends/GenericAgent.exe +``` + +#### Method 2 — Python install *(for developers)* -# 4. Launch +```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 ``` -**Also runs on Android** — tested successfully on Termux with `python agentmain.py` (CLI frontend): +> 💡 GenericAgent is meant to grow its environment **through the Agent itself**, not by pre-installing every possible package. + +📖 Full guide: [`docs/GETTING_STARTED.md`](docs/GETTING_STARTED.md) + +--- + +## 💻 Usage + +### 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). Supports multiple concurrent sessions and real-time streaming. ```bash -# In Termux -cd /sdcard/ga -python agentmain.py +python frontends/tuiapp_v2.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. `textual` is not on the latest version — `pip install -U textual` 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/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."* -| | 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, WeChat, QQ, Feishu / Lark, WeCom, and DingTalk. + +| Platform | Command | +| :--- | :--- | +| Telegram | `python frontends/tgapp.py` | +| WeChat | `python frontends/wechatapp.py` | +| QQ | `python frontends/qqapp.py` | +| Feishu / Lark | `python frontends/fsapp.py` | +| WeCom | `python frontends/wecomapp.py` | +| DingTalk | `python frontends/dingtalkapp.py` | + +> For detailed setup, ask GenericAgent itself. + +### Common Chat Commands + +| Command | Description | +| :--- | :--- | +| `/new` | Start a fresh conversation and clear the current context | +| `/continue` | List recoverable conversation snapshots | +| `/continue N` | Restore the `N`-th recoverable conversation | + +--- + +## 🧠 Architecture + +GenericAgent accomplishes complex tasks through **Layered Memory × Minimal Toolset × Autonomous Execution Loop**, continuously accumulating experience during execution. + +### 1️⃣ Layered Memory System + +> *Memory crystallizes throughout task execution, letting the agent build stable, efficient working patterns over time.* + +| Layer | Name | Description | +| :---: | :--- | :--- | +| **L0** | Meta Rules | Core behavioral rules and system constraints | +| **L1** | Insight Index | Minimal memory index for fast routing and recall | +| **L2** | Global Facts | Stable knowledge accumulated over long-term operation | +| **L3** | Task Skills / SOPs | Reusable workflows for completing specific task types | +| **L4** | Session Archive | Archived task records distilled from finished sessions for long-horizon recall | + +### 2️⃣ Autonomous Execution Loop + +> *Perceive environment state → Task reasoning → Execute tools → Write experience to memory → Loop* + +The entire core loop is just **~100 lines of code** ([`agent_loop.py`](agent_loop.py)). + +### 3️⃣ Minimal Toolset + +> *GenericAgent provides only **9 atomic tools**, forming the foundational capabilities for interacting with the outside world.* + +| Tool | Function | +| :--- | :--- | +| `code_run` | Execute arbitrary code (Python / PowerShell) | +| `file_read` | Read files | +| `file_write` | Write / create / overwrite files | +| `file_patch` | Patch / modify files | +| `web_scan` | Perceive web content | +| `web_execute_js` | Control browser behavior | +| `ask_user` | Human-in-the-loop confirmation | +| `update_working_checkpoint` | *(memory)* Short-term working notepad | +| `start_long_term_update` | *(memory)* Distill long-term memory | + +### 4️⃣ Capability Extension + +> *Capable of dynamically creating new tools.* + +Via `code_run`, GenericAgent can dynamically install Python packages, write new scripts, call external APIs, or control hardware at runtime — crystallizing temporary abilities into permanent tools. + +
+ GenericAgent Workflow +
GenericAgent Workflow Diagram +
+ +--- + +## 🧬 Self-Evolution Mechanism + +This is what fundamentally distinguishes GenericAgent from every other agent framework. + +```text +[New Task] + │ + ▼ +[Autonomous Exploration] ─► install deps · write scripts · debug · verify + │ + ▼ +[Crystallize into Skill] ─► write to memory layer + │ + ▼ +[Direct Recall on Next Similar Task] ``` -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** | +| *"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 +--- + +## 📊 Comparison -**Interface** (talk to the agent): -- `stapp.py` — Streamlit web UI -- `tgapp.py` — Telegram bot interface -- `launch.pyw` — One-click launcher with floating window +| 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 | -**Infrastructure**: -- `TMWebDriver.py` — Browser injection bridge (not Selenium — injects JS into your real browser via Tampermonkey) -- `simphtml.py` — HTML→text cleaner for web perception +--- -**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) +## 📈 Evaluation -Everything else — Gmail integration, WeChat automation, vision APIs, game downloaders, stock analysis workflows — the agent builds and memorizes on its own through use. +> 📂 Full evaluation datasets and results: [**JinyiHan99/GA-Technical-Report**](https://github.com/JinyiHan99/GA-Technical-Report/tree/main) -
+We evaluate GenericAgent across **five dimensions**: + +| # | Dimension | Question | Benchmarks | +| :---: | :--- | :--- | :--- | +| 1 | **Task Completion & Token Efficiency** | Can GA complete hard tasks more cheaply than leading agents? | SOP-Bench, Lifelong AgentBench, RealFin-Benchmark | +| 2 | **Tool-Use Efficiency** | Can a minimal atomic toolset solve what specialized toolsets solve, with less overhead? | Tool Efficiency Benchmark (11 simple + 5 long-horizon) | +| 3 | **Memory System Effectiveness** | Does condensed hierarchical memory beat full/redundant memory and embedding-based retrievers? | SOP-Bench (dangerous goods), LoCoMo, 20-skill stress test | +| 4 | **Self-Evolution Capability** | Can the agent distill experience into reusable SOPs and code, without intervention? | 9-round LangChain longitudinal study, 8-task cross-task web benchmark | +| 5 | **Web Browsing Capability** | Does density-driven design survive the open web? | WebCanvas, BrowseComp-ZH, Custom Tasks (22) | + +Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and **OpenClaw**, evaluated under *Claude Sonnet 4.6*, *Claude Opus 4.6*, *GPT-5.4*, and *MiniMax M2.7* backbones. + + + + + + +
+ Tool-use efficiency radar
+ Tool-use efficiency radar. GA dominates token, request, and tool-call axes while preserving quality across four task dimensions. +
+ Cross-task self-evolution convergence
+ Cross-task self-evolution. Second- and third-run GA executions converge to a stable low-cost regime across eight web tasks, while OpenClaw shows no such convergence. +
+ +--- + +## 📅 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). +- **2026-03-08** — [Released "Dintal Claw" — a GenericAgent-powered government-affairs bot](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg). +- **2026-03-01** — [Featured by Jiqizhixin (机器之心)](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg). +- **2026-01-16** — GenericAgent **V1.0** public release. --- - +## ⭐ Community & Support + +If this project helped you, please consider leaving a **Star!** 🙏 -# GenericAgent — 3,300 行代码,完整 OS 级自主控制 +You're also welcome to join the **GenericAgent Community Group** for discussion, feedback, and co-building 👏 -一个极简自主 Agent 框架。用约 3,300 行 Python,让任意 LLM 获得对你 PC 的物理级控制能力——浏览器、终端、文件系统、键鼠、屏幕视觉、移动设备。 +
+ + + + +
WeChat Group 21
WeChat Group 21 QR
+
+ +### 🚩 Friendly Links + +Thanks to the **LinuxDo** community for the support! + +[![LinuxDo](https://img.shields.io/badge/Community-LinuxDo-blue?style=for-the-badge)](https://linux.do/) + +**Community GUIs** *(independent open-source projects)*: + +- [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager) +- [wangjc683/galley](https://github.com/wangjc683/galley) — Out-of-the-box local agent workbench with a bundled GA runtime (CPython 3.11 + deps), native GUI/CLI, multi-session + Project orchestration, local-first. +- [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench) + +--- + +## 📄 License + +Distributed under the **MIT License**. See [`LICENSE`](LICENSE) for full text. + +> *Disclaimer: This project does not build or operate any commercial website. Apart from DintalClaw, no institution, organization, or individual is currently officially authorized to conduct commercial activities under the GenericAgent name.* + +--- -不需要 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 行种子代码中生长出来。 +```text +frontends/GenericAgent.exe +``` -## 自举哲学 +#### 方法二 — 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 +``` + +> 💡 GenericAgent 更推荐由 **Agent 在使用中自举环境**,而不是预先手动装完整依赖。 + +📖 完整引导流程见 [`docs/GETTING_STARTED.md`](docs/GETTING_STARTED.md) +📖 新手图文版:[飞书文档](https://my.feishu.cn/wiki/CGrDw0T76iNFuskmwxdcWrpinPb) +📘 完整入门教程(Datawhale 出品):[Hello GenericAgent](https://datawhalechina.github.io/hello-generic-agent/) · [GitHub](https://github.com/datawhalechina/hello-generic-agent) + +--- -5 个核心 SOP 定义了 Agent 如何思考、记忆和行动。之后的一切能力,由 Agent 在使用中自主发现并记录: +## 💻 使用方式 -1. 你让它做一件新事 -2. 它自己摸索方法(安装依赖、写脚本、测试) -3. 把流程保存为新 SOP -4. 下次直接调用 +### 前端启动 -Agent 不只是执行——它**学习并记忆**。 +#### 桌面端 -## 快速开始 +一键安装自带桌面端,双击: + +```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、微信、QQ、飞书 / Lark、企业微信、钉钉等 IM 前端。 + +| 平台 | 启动命令 | +| :--- | :--- | +| Telegram | `python frontends/tgapp.py` | +| 微信 | `python frontends/wechatapp.py` | +| QQ | `python frontends/qqapp.py` | +| 飞书 / Lark | `python frontends/fsapp.py` | +| 企业微信 | `python frontends/wecomapp.py` | +| 钉钉 | `python frontends/dingtalkapp.py` | + +> 详细配置直接问 GenericAgent。 + +### 通用聊天命令 + +| 命令 | 说明 | +| :--- | :--- | +| `/new` | 开启新对话并清空当前上下文 | +| `/continue` | 列出可恢复会话快照 | +| `/continue N` | 恢复第 `N` 个可恢复会话 | + +--- + +## 🧠 架构设计 + +GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环** 完成复杂任务,并在执行过程中持续积累经验。 + +### 1️⃣ 分层记忆系统 + +> *记忆在任务执行过程中持续沉淀,使 Agent 逐步形成稳定且高效的工作方式。* + +| 层级 | 名称 | 说明 | +| :---: | :--- | :--- | +| **L0** | 元规则(Meta Rules) | Agent 的基础行为规则和系统约束 | +| **L1** | 记忆索引(Insight Index) | 极简索引层,用于快速路由与召回 | +| **L2** | 全局事实(Global Facts) | 在长期运行过程中积累的稳定知识 | +| **L3** | 任务 Skills / SOPs | 完成特定任务类型的可复用流程 | +| **L4** | 会话归档(Session Archive) | 从已完成任务中提炼出的归档记录,用于长程召回 | + +### 2️⃣ 自主执行循环 + +> *感知环境状态 → 任务推理 → 调用工具执行 → 经验写入记忆 → 循环* + +整个核心循环仅 **约百行代码**([`agent_loop.py`](agent_loop.py))。 + +### 3️⃣ 最小工具集 + +> *GenericAgent 仅提供 **9 个原子工具**,构成与外部世界交互的基础能力。* + +| 工具 | 功能 | +| :--- | :--- | +| `code_run` | 执行任意代码(Python / PowerShell) | +| `file_read` | 读取文件 | +| `file_write` | 写入 / 创建 / 覆盖文件 | +| `file_patch` | 修改文件 | +| `web_scan` | 感知网页内容 | +| `web_execute_js` | 控制浏览器行为 | +| `ask_user` | 人机协作确认 | +| `update_working_checkpoint` | *(记忆)* 短期工作记事板 | +| `start_long_term_update` | *(记忆)* 提炼长期记忆 | + +### 4️⃣ 能力扩展机制 + +> *具备动态创建新工具的能力。* + +通过 `code_run`,GenericAgent 可在运行时动态安装 Python 包、编写新脚本、调用外部 API 或控制硬件,将临时能力固化为永久工具。 + +
+ GenericAgent 工作流程 +
GenericAgent 工作流程图 +
+ +--- + +## 🧬 自我进化机制 + +这是 GenericAgent 区别于其他 Agent 框架的根本所在。 + +```text +[遇到新任务] + │ + ▼ +[自主摸索] ─► 安装依赖 · 编写脚本 · 调试验证 + │ + ▼ +[执行路径固化为 Skill] ─► 写入记忆层 + │ + ▼ +[下次同类任务直接调用] ``` -启动后告诉 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 工具集 | +| 特性 | **GenericAgent** | OpenClaw | Claude Code | +| :--- | :---: | :---: | :---: | +| **代码量** | ~3K 行 | ~530,000 行 | 已开源(体量大) | +| **部署方式** | `pip install` + API Key | 多服务编排 | CLI + 订阅 | +| **浏览器控制** | 注入真实浏览器(保留登录态) | 沙箱 / 无头浏览器 | 通过 MCP 插件 | +| **OS 控制** | 键鼠、视觉、ADB | 多 Agent 委派 | 文件 + 终端 | +| **自我进化** | 自主生长 Skill 和工具 | 插件生态 | 会话间无状态 | +| **出厂配置** | 几个核心文件 + 少量初始 Skills | 数百模块 | 丰富 CLI 工具集 | -## 工作原理 +--- -Agent 拥有 7 个原子工具:`code_run`(执行任意代码)、`file_read/write/patch`(文件操作)、`web_scan`(网页感知)、`web_execute_js`(浏览器控制)、`ask_user`(人机协作)。 +## 📈 评测 -通过 `code_run`,它可以安装任何包、编写任何脚本、对接任何硬件——相当于在运行时制造新工具。学到的流程保存为 SOP,下次直接调用。 +> 📂 完整的评测数据集以及评测结果见:[**JinyiHan99/GA-Technical-Report**](https://github.com/JinyiHan99/GA-Technical-Report/tree/main) -核心循环只有 92 行(`agent_loop.py`):感知 → 思考 → 行动 → 记忆。 +我们从 **五大维度** 评测 GenericAgent: -
-出厂清单 +| # | 维度 | 核心问题 | 使用的基准 | +| :---: | :--- | :--- | :--- | +| 1 | **任务完成度与 Token 效率** | GA 能否以更低成本完成高难度任务? | SOP-Bench、Lifelong AgentBench、RealFin-Benchmark | +| 2 | **工具使用效率** | 最小原子工具集能否以更低开销替代专用工具集? | Tool Efficiency Benchmark | +| 3 | **记忆系统有效性** | 精简分层记忆能否超越冗余记忆和基于 Embedding 的检索器? | SOP-Bench、LoCoMo、20-skill 压力测试 | +| 4 | **自我进化能力** | Agent 能否在无人干预下将经验提炼为可复用的 SOP 与代码? | 9 轮 LangChain 纵向研究、8 任务跨任务 Web 基准 | +| 5 | **网页浏览能力** | 信息密度驱动设计能否适应开放网页? | WebCanvas、BrowseComp-ZH、自定义任务 | -**核心引擎**: -- `agent_loop.py` — 感知-思考-行动循环(92 行) -- `ga.py` — 工具定义与执行 -- `sidercall.py` — LLM 通信(多后端) -- `agentmain.py` — 会话编排 +以上维度的基线包括 **Claude Code**、**OpenAI CodeX** 和 **OpenClaw**,分别在 *Claude Sonnet 4.6*、*Claude Opus 4.6*、*GPT-5.4* 和 *MiniMax M2.7* 底座上进行评测。 -**交互界面**: -- `stapp.py` — Streamlit Web UI -- `tgapp.py` — Telegram 机器人 -- `launch.pyw` — 一键启动 + 悬浮窗 + + + + + +
+ 工具使用效率雷达图
+ 工具使用效率雷达图。GA 在 Token、请求数和工具调用轴上全面领先,同时在四个任务维度上保持质量。 +
+ 跨任务自我进化收敛曲线
+ 跨任务自我进化。GA 的第二轮和第三轮执行在 8 个 Web 任务上收敛至稳定的低成本区间。 +
-**基础设施**: -- `TMWebDriver.py` — 浏览器注入桥接(非 Selenium,通过 Tampermonkey 注入真实浏览器) -- `simphtml.py` — HTML→文本清洗 +--- -**5 个核心 SOP**(出厂自带,版本控制): -1. `memory_management_sop` — L0 宪法:Agent 如何管理自身记忆 -2. `autonomous_operation_sop` — 自主任务执行 -3. `scheduled_task_sop` — 定时任务 -4. `web_setup_sop` — 浏览器环境引导 -5. `ljqCtrl_sop` — 桌面物理控制(键鼠、DPI 感知) +## 📅 路线图与最新动态 + +- **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** 公开版本发布。 -其余一切——Gmail、微信自动化、视觉 API、游戏下载、股票分析——都是 Agent 在使用中自主构建并记忆的。 +--- -
+## ⭐ 社区与支持 + +如果这个项目对你有帮助,欢迎点一个 **Star!** 🙏 + +也欢迎加入 **GenericAgent 体验交流群**,一起交流、反馈、共建 👏 + +
+ + + + +
微信群 21
微信群 21 二维码
+
+ +### 🚩 友情链接 + +感谢 **LinuxDo** 社区的支持! + +[![LinuxDo](https://img.shields.io/badge/社区-LinuxDo-blue?style=for-the-badge)](https://linux.do/) + +**社区 GUI 客户端** *(独立开源项目)*: + +- [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager) +- [wangjc683/galley](https://github.com/wangjc683/galley) —— 开箱即用的本地 Agent 工作台,自带 GA 内核(内置 CPython 3.11 + 运行依赖),GUI/CLI 双原生、多 session + Project 编排、本地优先。 +- [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench) + +--- + +## 📄 许可 + +基于 **MIT License** 发布,详见 [`LICENSE`](LICENSE)。 + +> *声明:本项目未构建任何商业站点;除 DintalClaw 外,目前未官方授权任何机构、组织或个人以 GenericAgent 名义从事商业活动。* + +--- + +## 📈 Star History + +
-## 许可 + + + + + Star History Chart + + -MIT \ No newline at end of file +

+
diff --git a/TMWebDriver.py b/TMWebDriver.py index 86a2b0b05..b2c4f7d68 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']) @@ -98,7 +98,7 @@ def link(): 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) + return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False) return 'ok' def run(): from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler @@ -128,16 +128,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 +175,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 +203,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 +218,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 +243,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启动master") def get_all_sessions(self): if self.is_remote: @@ -261,9 +279,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..cac8d18ee 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('--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 not task: + time.sleep(getattr(mod, 'INTERVAL', 5)); continue + 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 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

+
+ + + + +
+
+""" + +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..eb7bc46ae --- /dev/null +++ b/assets/configure_mykey.py @@ -0,0 +1,1406 @@ +#!/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 = [ + # ═══════════════════════════ 直连 API(按旗舰能力降序)═══════════════════════════ + { + 'id': 'anthropic', + 'name': 'Anthropic Claude 官方直连', + 'desc': 'Claude Opus #1 最强模型,原生 tool 协议,sk-ant- 开头', + '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/ 获取', + 'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'], + }, + { + 'id': 'openai', + 'name': 'OpenAI GPT-5.5 / o 系列', + 'desc': 'GPT-5.5 旗舰,与 Claude Opus 同级', + '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 获取', + 'model_choices': ['gpt-5.5', 'gpt-5.4'], + }, + { + '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

-
    No available 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/global_mem_insight_template.txt b/assets/global_mem_insight_template.txt index 207b0e2c4..3385393c4 100644 --- a/assets/global_mem_insight_template.txt +++ b/assets/global_mem_insight_template.txt @@ -1,17 +1,23 @@ # [Global Memory Insight] -# 【引导注释 - 使用后请删除整个注释块】 -# 格式:两层「场景→记忆」映射 + RULES,总计≤30行 -# -# == 第一层:高频场景 key→value == -# 每行格式: 触发场景关键词: sop名/py名/L2:section名(关键参数) -# 示例: 境外网站/API: proxies={'https':'http://127.0.0.1:2082'} -# 示例: 微信发消息: wechat_send_sop | 微信读聊天: wechat_db_sop+wechat_db_utils(quick_connect) -# 可用 | 分隔同类场景,括号内放硬参数 -# -# == 第二层:低频关键词,自包含可只写一个词 == -# 格式: 一行内用 | 分隔多个关键词,需要时 read L2 或 ls memory/ 查详情 -# 示例: 邮件ezgmail | 游戏game | OCR | 华为云Huawei_Cloud -# -# == [RULES] == -# 压缩的通用操作准则,每条一行 -# 示例: 1. 搜索先行: 文件用es, 信息用google, 禁猜路径 \ No newline at end of file +需要时read L2 或 ls ../memory/ 查L3 +L0(META-SOP): memory_management_sop +L2: 现空 +L3: memory_cleanup_sop(记忆整理) | skill_search | ui_detect.py | ocr_utils.py | subagent | web_setup_sop | plan_sop +| procmem_scanner | keychain | ljqCtrl_sop+.py | tmwebdriver_sop | autonomous_operation_sop | scheduled_task_sop | vision_sop | adb_ui.py +L4: L4_raw_sessions/ 历史会话 + +浏览器特殊操作: tmwebdriver_sop(文件上传/图搜/PDF blob/物理坐标/HttpOnly Cookie/autofill突破/跨域iframe/CDP/跨tab) +键鼠: ljqCtrl_sop(禁pyautogui/先activate) +UI操作/截图/视觉: computer_use.md | vision_sop | 窗口截图必用ljqCtrl +定时:scheduled_task_sop | 自主:autonomous_operation_sop | watchdog/反射:agentmain --reflect +手机:adb_ui.py + +[RULES] +1. 搜索先行: 搜文件名严禁不用es(禁PS递归/禁dir遍历), 搜索一定优先使用web工具的google(严禁duckduckgo等), 优先看cwd,禁猜路径 +2. 交叉验证: 禁信摘要, 数值进详情页核实 +3. 编码安全: 禁PS cat/type用file_read; 改前必读; memory模块直接import(已在PATH,禁加虚假前缀) +4. 闭环: 物理模拟后确认; 3次失败请求干预; Git完整闭环 +5. 进程: 禁无条件杀python(杀自己), 精确PID, 禁os.kill判活 +6. 窗口: GUI状态优先win32gui枚举标题 +7. web JS: 输入用原生setter+事件链, 点击前检disabled, 注意引号转义; scan空/不全先稍等再scan, 禁首扫定论 +8. SOP: 读SOP禁凭印象,有utils必用 | 复杂超长程任务/用户明确提及规划模式→读plan_sop diff --git a/assets/global_mem_insight_template_en.txt b/assets/global_mem_insight_template_en.txt new file mode 100644 index 000000000..0ab46ea05 --- /dev/null +++ b/assets/global_mem_insight_template_en.txt @@ -0,0 +1,23 @@ +# [Global Memory Insight] +Read L2 or ls ../memory/ for L3 when needed +L0(META-SOP): memory_management_sop +L2: currently empty +L3: memory_cleanup_sop(memory cleanup) | skill_search | ui_detect.py | ocr_utils.py | subagent | web_setup_sop | plan_sop +| procmem_scanner | keychain | ljqCtrl_sop+.py | tmwebdriver_sop | autonomous_operation_sop | scheduled_task_sop | vision_sop | adb_ui.py +L4: L4_raw_sessions/ historical sessions + +Browser special ops: tmwebdriver_sop(file upload/image search/PDF blob/physical coords/HttpOnly Cookie/autofill bypass/cross-origin iframe/CDP/cross-tab) +Keyboard/Mouse: ljqCtrl_sop(no pyautogui/activate first) +UI/Screenshot/Vision: computer_use.md | vision_sop | window screenshot use ljqCtrl +Scheduling: scheduled_task_sop | Autonomous: autonomous_operation_sop | watchdog/reflect: agentmain --reflect +Mobile: adb_ui.py + +[RULES] +1. Search first: must use es for filename search (no PS recursion/no dir traversal), always prefer Google for web search (no duckduckgo etc), check cwd first, no guessing paths +2. Cross-verify: never trust summaries, verify numbers on detail pages +3. Encoding safety: use file_read not PS cat/type; read before modify; import memory modules directly (already in PATH, no fake prefixes) +4. Close the loop: confirm after physical simulation; request intervention after 3 failures; complete Git workflow +5. Processes: never kill python unconditionally (kills self), use exact PID, no os.kill for liveness check +6. Windows: prefer win32gui title enumeration for GUI state +7. Web JS: use native setter + event chain for input, check disabled before click, mind quote escaping; if scan empty/incomplete wait then rescan, no conclusions from first scan +8. SOP: read SOPs not from memory, must use utils if available | complex long-running/user mentions planning -> 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..57860cb15 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..436978d1f --- /dev/null +++ b/assets/tmwd_cdp_bridge/disable_dialogs.js @@ -0,0 +1,24 @@ +// Disable alert/confirm/prompt to prevent page JS from blocking extension +(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..0ba8b0ef8 100644 --- a/assets/tools_schema.json +++ b/assets/tools_schema.json @@ -1,68 +1,74 @@ [ {"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}, + "keyword": {"type": "string", "description": "[Optional] If provided, returns first match (case-insensitive) with context"}, + "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..17f14c3a3 --- /dev/null +++ b/assets/tools_schema_cn.json @@ -0,0 +1,74 @@ +[ + {"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}, + "keyword": {"type": "string", "description": "可选搜索关键字。如果提供,将返回第一个匹配项(忽略大小写)及其周边的内容"}, + "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/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">.*?" 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..d77bf5432 --- /dev/null +++ b/frontends/conductor.html @@ -0,0 +1,468 @@ + + + + + + Conductor + + + +
    +
    + +
    + +
    +
    +
    Subagents
    +
    点击卡片展开 · live
    +
    +
    +
    暂无 subagent。
    +
    +
    + + +
    +
    + 🧠 Conductor 输出 + +
    +
    +
    +
    + + +
    + +
    +
    +
    待批任务 0
    +
    conductor 拟派 subagent · 待你批复
    +
    +
    +
    + + +
    +
    +
    Conversation
    +
    connecting...
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + + + + diff --git a/frontends/conductor.py b/frontends/conductor.py new file mode 100644 index 000000000..167241cc3 --- /dev/null +++ b/frontends/conductor.py @@ -0,0 +1,475 @@ +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: float = field(default_factory=time.time) + updated_at: float = field(default_factory=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 = 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 = 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 = 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刚完成,你要审查其输出,把值得用户关注的内容转化成"可点击执行"的待批任务(approval)。 +先读L2记忆中User相关,推荐的动作和措辞要符合用户画像。 +要求: +1. 不要只凭采集摘要;重要事实要核实,需要判断时先派subagent补做必要调查,再下结论。 +2. 不要推"去看看/研究一下"这种半成品。推荐必须是最后一步可直接执行的动作(发某段微信回复、回复某封邮件草稿、处理某PR、整理某文件等)。 +3. POST /approval 推送,prompt里同时写清两部分: + ① 奏折式报告给用户拍板:背景(什么事/来自谁) + 已核实(你做了哪些调查/关键事实) + 判断(为什么这样建议) + 风险。用户看完这段就能直接拍板,不用再去翻原消息。 + ② 用户同意后该执行的完整任务指令(approval通过会直接作为subagent的prompt派发,必须具体到可直接执行)。 +4. 没有值得用户点击执行的动作就直接结束,不要打扰;尤其不要对执行回执/完成确认/纯闲聊报"无需关注"。 +5. 判断标准:私聊默认重要,群聊除非@用户或与用户工作相关否则忽略。""", +} + +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") + summary = f"subagents: {running} running, {stopped} stopped | {unread}条用户未读消息, {done_count}个subagent完成报告" + summary += "".join(f"\n[IM信号] {e['source']} 有新消息(GET /im_prompt/{e['source']} 取采集prompt),尽量复用已有subagent" for e in events if e.get("type") == "im_signal") + base = f"http://{HOST}:{PORT}" + return f"""你是agent总管。用户只和你对话,你负责调度、验收、交付,目标是降低用户管理多个agent的负担。 +API: {base};先requests,GET /readme查用法,GET /chat读未读对话,GET /subagent看状态;POST /chat是唯一对用户说话方式。 +流程文档按需读取: GET /readme/usermsg | GET /readme/subagent | GET /readme/im + +铁律: +- 绝不亲自执行任务/探测环境;一切执行交给subagent。你只分析、派遣、审查、沟通。 +- 每次唤醒只做最小必要动作(发消息/开subagent/reply/keyinfo/abort),做完立刻停,等待下次事件唤醒。 +- 改写prompt时严禁添加用户未提及的假设、工具、前提条件。只能精炼/结构化用户原意,不能脑补,只能做很小的改写 + +原则: +- 信任subagent足够聪明,不要写具体步骤和容易探测的信息;能自己判断的自己判断,只在真正需要用户决策时打扰。 +{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 = 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..f4d533706 --- /dev/null +++ b/frontends/continue_cmd.py @@ -0,0 +1,606 @@ +"""`/continue` command: list & restore past model_responses sessions. +Pure functions + one `install(cls)` monkey-patch entry. No side effects at import. +""" +import ast, glob, json, os, re, 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 = (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): + """Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only.""" + 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)] + 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]', '### [总结提炼经验]') + + +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 = (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:72 verbose tool-call header.""" + name = block.get('name', '?') + args = block.get('input', {}) + 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) + + +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 + + +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..849cf2824 --- /dev/null +++ b/frontends/desktop/static/app.js @@ -0,0 +1,2113 @@ +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 = ` +
    ${escapeHtml(turnHeaderLabel(turnIndex, displayKind))}
    +
    `; + 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); + 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; + 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.

    +

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

    + + +
    + +
    +

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

    + + +
    + +
    +

    + The folder containing frontends/desktop_bridge.py +

    + + +
    +
    + + + diff --git a/frontends/desktop/static/ga-web.js b/frontends/desktop/static/ga-web.js new file mode 100644 index 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 + + + +
    + +
    +
    +
    +
    + +
    +
    +
    +

    GenericAgent

    + + + Starting… + +
    +
    + + + +
    +
    +
    New task
    +
    Task me anything. Type /help for commands.
    +
    +
    + +
    +
    +
    + +
    +
    + + +
    + +
    +
    +
    +
    + + + + + + + + +
    + + + + + + 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 + + +
    Starting 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:/^$/,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]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?=[ \\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",")|<(?: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",")|<(?: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",")|<(?:script|pre|style|textarea|!--)").replace("tag",d).getRegex()},$={...w,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\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:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\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-]*(?: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+"\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\n"+t+"\n"+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`}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='
    ",i}image({href:e,title:t,text:n}){const s=Y(e);if(null===s)return W(n);let r=`${n}{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.pyw b/frontends/desktop_pet.pyw new file mode 100644 index 000000000..c0bd598e2 --- /dev/null +++ b/frontends/desktop_pet.pyw @@ -0,0 +1,93 @@ +"""Desktop Pet with HTTP Toast — ~90 lines""" +import tkinter as tk, threading, random, os, sys +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs + +PORT = 41983 +GIF = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), 'pet.gif') + +class Pet: + def __init__(self): + self.root = tk.Tk() + self.root.overrideredirect(True) + self.root.wm_attributes('-topmost', True) + self.root.wm_attributes('-transparentcolor', '#01FF01') + self.root.config(bg='#01FF01') + self.root.after(50, lambda: self.root.geometry('+300+500')) + # load GIF frames + self.frames, i = [], 0 + while True: + try: self.frames.append(tk.PhotoImage(file=GIF, format=f'gif -index {i}')); i += 1 + except: break + if not self.frames: raise FileNotFoundError(f'No GIF: {GIF}') + self.idx = 0 + self.label = tk.Label(self.root, image=self.frames[0], bg='#01FF01', bd=0) + self.label.pack() + # drag + 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))) + # start loops + self._animate() + self._wander() + self._start_server() + self.root.mainloop() + + def _drag(self, e): + x, y = self.root.winfo_x() + e.x - self._d[0], self.root.winfo_y() + e.y - self._d[1] + self.root.geometry(f'+{x}+{y}') + + def _animate(self): + self.idx = (self.idx + 1) % len(self.frames) + self.label.config(image=self.frames[self.idx]) + self.root.after(150, self._animate) + + def _wander(self): + if random.random() < 0.25: + x = self.root.winfo_x() + random.randint(-15, 15) + y = self.root.winfo_y() + random.randint(-5, 5) + self.root.geometry(f'+{x}+{y}') + self.root.after(4000, self._wander) + + def show_toast(self, msg): + """Show a speech bubble near the pet that auto-dismisses.""" + tw = tk.Toplevel(self.root) + tw.overrideredirect(True) + tw.wm_attributes('-topmost', True) + tw.config(bg='#FFFDE7') + px, py = self.root.winfo_x(), self.root.winfo_y() + tw.geometry(f'+{px + 30}+{py - 50}') + # bubble content + f = tk.Frame(tw, bg='#FFFDE7', highlightbackground='#888', highlightthickness=1, padx=8, pady=4) + f.pack() + tk.Label(f, text=msg, bg='#FFFDE7', fg='#333', font=('Segoe UI', 10), wraplength=220, justify='left').pack() + # auto dismiss + tw.after(3000, tw.destroy) + + def _start_server(self): + pet = self + class H(BaseHTTPRequestHandler): + def do_GET(self): + qs = parse_qs(urlparse(self.path).query) + msg = qs.get('msg', [''])[0] + if msg: + pet.root.after(0, pet.show_toast, 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'?msg=xxx') + def do_POST(self): + body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode() + if body: + pet.root.after(0, pet.show_toast, 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 + HTTPServer.allow_reuse_address = False + srv = HTTPServer(('127.0.0.1', PORT), H) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + print(f'Toast server: http://127.0.0.1:{PORT}/?msg=hello') + +if __name__ == '__main__': + Pet() 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">.*?" 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/pet.gif b/frontends/pet.gif new file mode 100644 index 000000000..a297e0fbd Binary files /dev/null and b/frontends/pet.gif differ diff --git a/frontends/plan_state.py b/frontends/plan_state.py new file mode 100644 index 000000000..c93fb42e4 --- /dev/null +++ b/frontends/plan_state.py @@ -0,0 +1,180 @@ +"""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) -> bool: + """Plan mode is on. Primary: `working['in_plan_mode']`. Fallback: + a `plan_*/plan.md` referenced in this session's messages (no global scan).""" + if _stashed_plan_path(agent): return True + return find_path_in_messages(messages, start_idx) is not None + + +def resolve_path(agent, messages=None, start_idx: int = 0) -> Optional[str]: + p = _resolve_stashed(_stashed_plan_path(agent)) + 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..5c49c7f32 --- /dev/null +++ b/frontends/stapp.py @@ -0,0 +1,342 @@ +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") + +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): pet_script = os.path.join(script_dir, 'desktop_pet.pyw') + 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 +_js_scroll_fix = ( + "!function(){var p=window.parent;if(p.__sfx2)return;p.__sfx2=1;var d=p.document;" + "var pending=0;" + "function f(){pending=0;var m=d.querySelector('section.main');if(!m)return;" + "var s=m.scrollTop,h=m.scrollHeight;" + "m.style.minHeight=h+1+'px';void m.offsetHeight;" + "m.style.minHeight='';void m.offsetHeight;" + "m.scrollTop=s}" + "function schedule(){if(!pending){pending=1;requestAnimationFrame(f)}}" + "d.addEventListener('transitionend',function(e){" + "e.target.closest&&e.target.closest('details')&&setTimeout(schedule,60)},!0);" + "new MutationObserver(function(){setTimeout(schedule,80)})" + ".observe(d.body,{subtree:1,attributes:1,attributeFilter:['open']})}()" +) +# 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"""""", 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..57efeb8f9 --- /dev/null +++ b/frontends/tui_v3.py @@ -0,0 +1,5589 @@ +"""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 + +# ════════════════════════════════════════════════════════════════════════════ +# 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', + '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.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 取消', + '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.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)>.*?', 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 + # 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') + 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 (
    ///

    /