chore: sync framework to upstream lsdefine/GenericAgent @ c85b59e (excluding conductor.py + frontends/desktop/)
This commit is contained in:
1 parent
de00ede0cf
commit
eaffd01e6c
52 files changed
+7865
-1030
No files matched your search
+4
-4
@@ -53,6 +53,7 @@ memory/L4_raw_sessions/*
|
||||
# ljqCtrl related tools
|
||||
!memory/ljqCtrl.py
|
||||
!memory/ljqCtrl_sop.md
|
||||
!memory/macljqCtrl.py
|
||||
!memory/computer_use.md
|
||||
|
||||
# procmem_scanner related tools
|
||||
@@ -87,10 +88,6 @@ memory/L4_raw_sessions/*
|
||||
# Morphling SOP
|
||||
!memory/morphling_sop.md
|
||||
|
||||
# Skill Search SOP
|
||||
!memory/skill_search/
|
||||
!memory/skill_search/**
|
||||
|
||||
|
||||
# ADB UI tool
|
||||
!memory/adb_ui.py
|
||||
@@ -147,5 +144,8 @@ frontends/conductor_im_plugins/config.local.json
|
||||
|
||||
.claude/
|
||||
|
||||
# Project Mode SOP
|
||||
!memory/project_mode_sop.md
|
||||
|
||||
# 本地 bug / 局限报告草稿(交给维护者参考,不进仓库)
|
||||
/BUG_*.md
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<p>
|
||||
|
||||
<a href="https://gaagent.ai"><img src="https://img.shields.io/badge/Official_Website-gaagent.ai-00A67E?style=flat-square" alt="Official Website"/></a>
|
||||
<a href="https://arxiv.org/abs/2604.17091"><img src="https://img.shields.io/badge/Technical_Report-PDF-EA4335?style=flat-square&logo=adobeacrobatreader&logoColor=white" alt="Technical Report"/></a>
|
||||
<a href="https://github.com/JinyiHan99/GA-Technical-Report"><img src="https://img.shields.io/badge/Code_%26_Data-Reproduction-181717?style=flat-square&logo=github" alt="Reproduction Repo"/></a>
|
||||
<a href="https://datawhalechina.github.io/hello-generic-agent/"><img src="https://img.shields.io/badge/Tutorial-Datawhale-blue?style=flat-square" alt="Tutorial"/></a>
|
||||
@@ -24,8 +25,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
> 📌 **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.
|
||||
> 📌 **Official:** GitHub + https://gaagent.ai only. DintalClaw is the sole authorized commercial partner; others are not affiliated.
|
||||
|
||||
---
|
||||
|
||||
@@ -47,6 +47,7 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe
|
||||
- [Demo Showcase](#-demo-showcase)
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Usage](#-usage)
|
||||
- [Unlocking Advanced Capabilities](#-unlocking-advanced-capabilities)
|
||||
- [Architecture](#-architecture)
|
||||
- [Self-Evolution Mechanism](#-self-evolution-mechanism)
|
||||
- [Comparison](#-comparison)
|
||||
@@ -63,7 +64,7 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe
|
||||
| :--- | :--- |
|
||||
| 🧬 **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. |
|
||||
| ⚡ **Strong Execution** | **TMWebdriver** injects into a real browser (preserving login sessions). 9 atomic tools take direct control of the system. |
|
||||
| 🔌 **High Compatibility** | Supports Claude / Gemini / Kimi / MiniMax and other major models. Cross-platform. |
|
||||
| 💰 **Token Efficient** | <30K context window — a fraction of the 200K–1M other agents consume. Less noise, fewer hallucinations, higher success rate, lower cost. |
|
||||
|
||||
@@ -73,8 +74,20 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%"><b>🧋 Food Delivery Order</b></td>
|
||||
<td align="center" width="50%"><b>📈 Quantitative Stock Screening</b></td>
|
||||
<td align="center" width="50%"><b>🛡️ Real-Browser CAPTCHA Survival</b></td>
|
||||
<td align="center" width="50%"><b>🌐 Autonomous Web Exploration</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="assets/demo/discord_hcaptcha_real_browser.gif" width="100%" alt="Discord hCaptcha passed in real browser"></td>
|
||||
<td><img src="assets/demo/autonomous_explore.png" width="100%" alt="Web Exploration"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub>While configuring a Discord bot, an hCaptcha <i>"Are you human?"</i> challenge pops up mid-task — GA's real browser session passes it and the task continues. See <a href="#browser-realness-of-ga-web-tools">Browser Realness</a>.</sub></td>
|
||||
<td><sub>Autonomously browses and periodically summarizes web content.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>🧋 Food Delivery Order</b></td>
|
||||
<td align="center"><b>📈 Quantitative Stock Screening</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="assets/demo/order_tea.gif" width="100%" alt="Order Tea"></td>
|
||||
@@ -85,25 +98,16 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe
|
||||
<td><sub><i>"Find GEM stocks with EXPMA golden cross, turnover > 5%"</i> — quantitative screening.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>🌐 Autonomous Web Exploration</b></td>
|
||||
<td align="center"><b>💰 Expense Tracking</b></td>
|
||||
<td align="center"><b>💬 Batch Messaging</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="assets/demo/autonomous_explore.png" width="100%" alt="Web Exploration"></td>
|
||||
<td><img src="assets/demo/alipay_expense.png" width="100%" alt="Alipay Expense"></td>
|
||||
<td align="center"><img src="assets/demo/wechat_batch.png" width="65%" alt="WeChat Batch"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub>Autonomously browses and periodically summarizes web content.</sub></td>
|
||||
<td><sub><i>"Find expenses over ¥2K in the last 3 months"</i> — drives Alipay via ADB.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>💬 Batch Messaging</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><img src="assets/demo/wechat_batch.png" width="50%" alt="WeChat Batch"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><sub>Sends bulk WeChat messages, fully driving the WeChat client.</sub></td>
|
||||
<td><sub>Sends bulk WeChat messages, fully driving the WeChat client.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -125,42 +129,40 @@ curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/ma
|
||||
|
||||
### For Humans
|
||||
|
||||
#### Method 1 — One-line install *(recommended)*
|
||||
#### Method 1 — Clone & install *(recommended)*
|
||||
|
||||
This installs GenericAgent with an isolated Python environment and Git, then downloads a ready-to-run package.
|
||||
```bash
|
||||
git clone https://github.com/lsdefine/GenericAgent.git && cd GenericAgent
|
||||
uv venv && uv pip install -e ".[ui]"
|
||||
cp mykey_template_en.py mykey.py # fill in your LLM API key
|
||||
```
|
||||
|
||||
Dependencies are deliberately tiered: the agent core needs only `requests`, plus four lightweight packages (`beautifulsoup4`, `bottle`, `simple-websocket-server`, `aiohttp`) for TMWebdriver's local server. The `[ui]` extra pulls in frontend libraries (Streamlit, `prompt_toolkit`/`rich` for the TUI, …) — install it for the bundled UIs, or skip it entirely and drive the agent headless. No Playwright, no LangChain, no browser binaries to download.
|
||||
|
||||
Then launch:
|
||||
|
||||
```bash
|
||||
python frontends/tui_v3.py # Terminal UI (recommended)
|
||||
python launch.pyw # Streamlit web UI
|
||||
```
|
||||
|
||||
#### Method 2 — One-line installer *(convenience)*
|
||||
|
||||
Sets up a self-contained directory with an isolated Python environment, Git, and a ready-to-run package. The script is in [`assets/`](assets/) if you'd like to read it first.
|
||||
|
||||
**Windows PowerShell**
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm http://fudankw.cn:9000/files/ga_install.ps1 | iex"
|
||||
powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.ps1 | iex"
|
||||
```
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
GLOBAL=1 bash -c "$(curl -fsSL http://fudankw.cn:9000/files/ga_install.sh)"
|
||||
GLOBAL=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.sh)"
|
||||
```
|
||||
|
||||
After installation, launch the desktop app from:
|
||||
|
||||
```text
|
||||
frontends/GenericAgent.exe
|
||||
```
|
||||
|
||||
#### Method 2 — Python install *(for developers)*
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
> 💡 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)
|
||||
> 💡 GenericAgent grows its environment **through the Agent itself** — don't pre-install everything. See [Unlocking Advanced Capabilities](#-unlocking-advanced-capabilities) below.
|
||||
|
||||
---
|
||||
|
||||
@@ -168,20 +170,12 @@ python launch.pyw
|
||||
|
||||
### Frontends
|
||||
|
||||
#### Desktop App
|
||||
#### Terminal UI *(recommended)*
|
||||
|
||||
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.
|
||||
A lightweight, scrollback-first terminal interface built on `prompt_toolkit` + `rich`. Supports multiple concurrent sessions and real-time streaming.
|
||||
|
||||
```bash
|
||||
python frontends/tuiapp_v2.py
|
||||
python frontends/tui_v3.py
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -189,10 +183,10 @@ python frontends/tuiapp_v2.py
|
||||
|
||||
TUI rendering on Windows can be flaky depending on terminal + font. Common causes:
|
||||
|
||||
1. `textual` is not on the latest version — `pip install -U textual` first.
|
||||
1. `prompt_toolkit` / `rich` are not on the latest version — `pip install -U prompt_toolkit rich` first.
|
||||
2. PowerShell / cmd ship with terminals that have rough Unicode + key-binding support. **Prefer Git Bash on Windows**, which is much better behaved.
|
||||
3. If it still looks broken, ask GA itself to fix it:
|
||||
> *"My experience using `frontends/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."*
|
||||
> *"My experience using `frontends/tui_v3.py` in PowerShell / cmd / Git Bash on Windows is very poor — lots of incompatibility. Please refer to Claude Code's best practices for the Windows terminal and fix all font and rendering incompatibilities."*
|
||||
|
||||
</details>
|
||||
|
||||
@@ -204,26 +198,41 @@ python launch.pyw
|
||||
|
||||
### Bot Interface (IM)
|
||||
|
||||
GenericAgent also supports IM frontends such as Telegram, WeChat, QQ, Feishu / Lark, WeCom, and DingTalk.
|
||||
GenericAgent also supports IM frontends such as Telegram, Discord, and Lark.
|
||||
|
||||
| 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` |
|
||||
| Discord | `python frontends/dcapp.py` |
|
||||
| Lark / Feishu | `python frontends/fsapp.py` |
|
||||
|
||||
> WeChat, QQ, WeCom and DingTalk are also supported — see the Chinese section below.
|
||||
> For detailed setup, ask GenericAgent itself.
|
||||
|
||||
### Common Chat Commands
|
||||
---
|
||||
|
||||
| Command | Description |
|
||||
## 🔓 Unlocking Advanced Capabilities
|
||||
|
||||
In GA, advanced capabilities are unlocked by **instructing the agent**, not by reading
|
||||
docs or installing extras. Each instruction below makes GA read its pre-installed SOPs
|
||||
(battle-tested playbooks in its memory), install whatever is missing, adapt to your OS,
|
||||
and persist the result into its own memory.
|
||||
|
||||
| Capability | Just tell GA |
|
||||
| :--- | :--- |
|
||||
| `/new` | Start a fresh conversation and clear the current context |
|
||||
| `/continue` | List recoverable conversation snapshots |
|
||||
| `/continue N` | Restore the `N`-th recoverable conversation |
|
||||
| 🌐 Web automation | *"Set up your web automation capability."* — GA guides you through the one manual step: dragging the bundled Chrome extension into `chrome://extensions`. |
|
||||
| 🔤 OCR | *"Set up your OCR capability with rapidocr and save it to memory."* |
|
||||
| 👁️ Vision | *"Set up your vision capability from the template in memory/."* — GA copies the template, wires it to your existing LLM keys, and self-tests. |
|
||||
| 🖱️ Computer use | *"Probe this system and set up your computer-use capability."* |
|
||||
|
||||
> 💡 **About language**: the pre-installed SOPs are written in Chinese — GA reads them
|
||||
> natively, so this never blocks you. If you prefer an English knowledge base, just say:
|
||||
> *"Read your pre-installed SOPs and rewrite them in English (keep code, paths and error
|
||||
> strings verbatim)."*
|
||||
>
|
||||
> 🌍 **About platforms**: the SOPs were honed on Windows, but cross-platform adaptation is
|
||||
> itself a GA task — on macOS/Linux, GA swaps in the platform equivalents (window
|
||||
> enumeration, input control, screenshots) on its own. Same self-evolution principle.
|
||||
|
||||
---
|
||||
|
||||
@@ -298,6 +307,7 @@ This is what fundamentally distinguishes GenericAgent from every other agent fra
|
||||
| What you say | First time | Every time after |
|
||||
| :--- | :--- | :--- |
|
||||
| *"Read my WeChat messages"* | Install deps → reverse DB → write read script → save Skill | **one-line invoke** |
|
||||
| *"Give me a morning digest of Hacker News"* | Write scraper → build digest → schedule daily run → save Skill | **one-line invoke** |
|
||||
| *"Monitor stocks and alert me"* | Install `mootdx` → build selection flow → configure cron → save Skill | **one-line start** |
|
||||
| *"Send this file via Gmail"* | Configure OAuth → write send script → save Skill | **ready to use** |
|
||||
|
||||
@@ -347,6 +357,21 @@ Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Browser Realness of GA Web Tools (TMWebdriver)
|
||||
|
||||
GA web tools are powered by **TMWebdriver** — a local WebSocket server plus a Chrome extension — running through a **real, persistent Chrome/Chromium session** rather than a disposable headless sandbox, preserving cookies, login state, extensions, GPU/WebGL behavior, and normal browser-session fingerprints.
|
||||
|
||||
| Detection Service / Signal | Vanilla Headless Automation | GA Web Tools | Notes |
|
||||
| :--- | :---: | :---: | :--- |
|
||||
| SannySoft headless test | Often detected | ✅ 56/56 passed | `bot.sannysoft.com` |
|
||||
| bot.incolumitas.com | Commonly fails webdriver / CDP checks | ✅ 36/36 passed | `WEBDRIVER`, `SELENIUM_DRIVER`, `webDriverAdvanced` all OK |
|
||||
| BrowserScan bot detection | Often abnormal | ✅ Normal | `browserscan.net` |
|
||||
| Device & Browser Info bot test | Multiple bot flags | ✅ Human / `isBot=false` | `deviceandbrowserinfo.com` |
|
||||
| FingerprintJS bot detection demo | Often detected | ✅ Passed | Demo flow completed without bot verdict |
|
||||
| reCAPTCHA v3 demo | Low bot-like score | ✅ 0.9 human-like score | Score-based risk signal; 0.9 is above typical production thresholds |
|
||||
|
||||
For reCAPTCHA v3, `0.9` is not a "checkbox solved" result; it is the high-confidence human-like score returned by the risk model, typically sufficient to avoid extra challenges in production flows.
|
||||
|
||||
---
|
||||
|
||||
## 📅 Roadmap & News
|
||||
@@ -361,9 +386,9 @@ Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and
|
||||
- **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-03-10** — [Released million-scale Skill Library](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7) *(Chinese)*.
|
||||
- **2026-03-08** — [Released "Dintal Claw" — a GenericAgent-powered government-affairs bot](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg) *(Chinese)*.
|
||||
- **2026-03-01** — [Featured by Jiqizhixin (机器之心)](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg) *(Chinese)*.
|
||||
- **2026-01-16** — GenericAgent **V1.0** public release.
|
||||
|
||||
---
|
||||
@@ -372,16 +397,6 @@ Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and
|
||||
|
||||
If this project helped you, please consider leaving a **Star!** 🙏
|
||||
|
||||
You're also welcome to join the **GenericAgent Community Group** for discussion, feedback, and co-building 👏
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>WeChat Group 20</strong><br/><img src="assets/images/wechat_group20.jpg" alt="WeChat Group 20 QR" width="240"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
### 🚩 Friendly Links
|
||||
|
||||
Thanks to the **LinuxDo** community for the support!
|
||||
@@ -393,6 +408,7 @@ Thanks to the **LinuxDo** community for the support!
|
||||
- [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager)
|
||||
- [wangjc683/galley](https://github.com/wangjc683/galley) — Out-of-the-box local agent workbench with a bundled GA runtime (CPython 3.11 + deps), native GUI/CLI, multi-session + Project orchestration, local-first.
|
||||
- [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench)
|
||||
- [Fwind43/GenericAgent-Admin](https://github.com/Fwind43/GenericAgent-Admin) — Go + React desktop admin panel: service lifecycle management, native chat, Goal mode, BBS team board, file editor, model config wizard, TMWebDriver monitor, self-update, and Windows tray/desktop-pet integration.
|
||||
|
||||
---
|
||||
|
||||
@@ -400,7 +416,7 @@ Thanks to the **LinuxDo** community for the support!
|
||||
|
||||
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.*
|
||||
> *Disclaimer: The official GenericAgent channels are this GitHub repository and https://gaagent.ai. DintalClaw is currently the only officially authorized commercial partner; any other third-party website, organization, or individual using the GenericAgent name is not official unless explicitly listed here.*
|
||||
|
||||
---
|
||||
|
||||
@@ -516,11 +532,10 @@ powershell -ExecutionPolicy Bypass -c "irm http://fudankw.cn:9000/files/ga_insta
|
||||
curl -fsSL http://fudankw.cn:9000/files/ga_install.sh | bash
|
||||
```
|
||||
|
||||
安装完成后,双击启动:
|
||||
安装完成后启动:
|
||||
|
||||
```text
|
||||
frontends/GenericAgent.exe
|
||||
```
|
||||
- **Windows** — 双击 `frontends/GenericAgent.exe`
|
||||
- **Linux / macOS** — 在安装目录运行 `python launch.pyw`
|
||||
|
||||
#### 方法二 — Python 安装 *(开发者)*
|
||||
|
||||
@@ -547,7 +562,7 @@ python launch.pyw
|
||||
|
||||
#### 桌面端
|
||||
|
||||
一键安装自带桌面端,双击:
|
||||
一键安装自带桌面端(Windows),双击:
|
||||
|
||||
```text
|
||||
frontends/GenericAgent.exe
|
||||
@@ -579,11 +594,12 @@ python launch.pyw
|
||||
|
||||
### Bot 接口(IM)
|
||||
|
||||
GenericAgent 支持 Telegram、微信、QQ、飞书 / Lark、企业微信、钉钉等 IM 前端。
|
||||
GenericAgent 支持 Telegram、Discord、微信、QQ、飞书 / Lark、企业微信、钉钉等 IM 前端。
|
||||
|
||||
| 平台 | 启动命令 |
|
||||
| :--- | :--- |
|
||||
| Telegram | `python frontends/tgapp.py` |
|
||||
| Discord | `python frontends/dcapp.py` |
|
||||
| 微信 | `python frontends/wechatapp.py` |
|
||||
| QQ | `python frontends/qqapp.py` |
|
||||
| 飞书 / Lark | `python frontends/fsapp.py` |
|
||||
@@ -592,14 +608,6 @@ GenericAgent 支持 Telegram、微信、QQ、飞书 / Lark、企业微信、钉
|
||||
|
||||
> 详细配置直接问 GenericAgent。
|
||||
|
||||
### 通用聊天命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
| :--- | :--- |
|
||||
| `/new` | 开启新对话并清空当前上下文 |
|
||||
| `/continue` | 列出可恢复会话快照 |
|
||||
| `/continue N` | 恢复第 `N` 个可恢复会话 |
|
||||
|
||||
---
|
||||
|
||||
## 🧠 架构设计
|
||||
@@ -721,6 +729,21 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### GA Web 工具的浏览器真实性
|
||||
|
||||
GA Web 工具运行在**真实、持久化的 Chrome/Chromium 会话**中,而不是一次性的 headless 沙箱,因此可以保留 Cookie、登录态、扩展、GPU/WebGL 行为以及正常浏览器会话指纹。
|
||||
|
||||
| 检测服务 / 信号 | 普通 Headless 自动化 | GA Web 工具 | 说明 |
|
||||
| :--- | :---: | :---: | :--- |
|
||||
| SannySoft headless test | 常被识别 | ✅ 56/56 通过 | `bot.sannysoft.com` |
|
||||
| bot.incolumitas.com | 常在 webdriver / CDP 项异常 | ✅ 36/36 通过 | `WEBDRIVER`、`SELENIUM_DRIVER`、`webDriverAdvanced` 全部 OK |
|
||||
| BrowserScan bot detection | 常显示异常 | ✅ Normal | `browserscan.net` |
|
||||
| Device & Browser Info bot test | 多个 bot 标记 | ✅ Human / `isBot=false` | `deviceandbrowserinfo.com` |
|
||||
| FingerprintJS bot detection demo | 常被识别 | ✅ 通过 | Demo 流程完成,未给出 bot 判定 |
|
||||
| reCAPTCHA v3 demo | 低分 / bot-like | ✅ 0.9 真人相似分 | v3 是基于分数的风险信号;0.9 高于常见生产阈值 |
|
||||
|
||||
对于 reCAPTCHA v3,`0.9` 不是“点过验证码”的结果,而是风控模型返回的高置信真人相似分,通常足以通过生产环境中的常见阈值,避免进入更严格挑战。
|
||||
|
||||
---
|
||||
|
||||
## 📅 路线图与最新动态
|
||||
@@ -751,7 +774,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>微信群 20</strong><br/><img src="assets/images/wechat_group20.jpg" alt="微信群 20 二维码" width="240"/></td>
|
||||
<td align="center"><strong>微信群 21</strong><br/><img src="assets/images/wechat_group21.jpg" alt="微信群 21 二维码" width="240"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -767,6 +790,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
|
||||
- [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager)
|
||||
- [wangjc683/galley](https://github.com/wangjc683/galley) —— 开箱即用的本地 Agent 工作台,自带 GA 内核(内置 CPython 3.11 + 运行依赖),GUI/CLI 双原生、多 session + Project 编排、本地优先。
|
||||
- [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench)
|
||||
- [Fwind43/GenericAgent-Admin](https://github.com/Fwind43/GenericAgent-Admin) —— Go + React 桌面管理面板:服务生命周期管理、原生 Chat、Goal 模式、BBS 团队看板、文件编辑器、模型配置向导、TMWebDriver 监控、自更新,以及 Windows 托盘/桌面宠物集成。
|
||||
|
||||
---
|
||||
|
||||
@@ -774,7 +798,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
|
||||
|
||||
基于 **MIT License** 发布,详见 [`LICENSE`](LICENSE)。
|
||||
|
||||
> *声明:本项目未构建任何商业站点;除 DintalClaw 外,目前未官方授权任何机构、组织或个人以 GenericAgent 名义从事商业活动。*
|
||||
> *声明:GenericAgent 官方渠道为本 GitHub 仓库和 https://gaagent.ai。DintalClaw 是目前唯一官方授权的商业合作方;除非在此处明确列出,其他使用 GenericAgent 名义的第三方网站、机构、组织或个人均非官方。*
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-7
@@ -93,12 +93,11 @@ class TMWebDriver:
|
||||
session_id = data.get('sessionId')
|
||||
code = data.get('code')
|
||||
timeout = float(data.get('timeout', 10.0))
|
||||
try:
|
||||
result = self.execute_js(code, timeout=timeout, session_id=session_id)
|
||||
print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' '))
|
||||
return json.dumps({'r': result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False)
|
||||
try: result = self.execute_js(code, timeout=timeout, session_id=session_id)
|
||||
except Exception as e: return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False)
|
||||
try: print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' '))
|
||||
except Exception: pass
|
||||
return json.dumps({'r': result}, ensure_ascii=False)
|
||||
return 'ok'
|
||||
def run():
|
||||
from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler
|
||||
@@ -245,7 +244,7 @@ class TMWebDriver:
|
||||
def _remote_cmd(self, cmd):
|
||||
try: return requests.post(self.remote, headers={"Content-Type": "application/json"}, json=cmd, timeout=30).json()
|
||||
except (ConnectionError, requests.exceptions.ConnectionError):
|
||||
raise ConnectionError("TMWebDriver master未运行,看tmwebdriver_sop启动master")
|
||||
raise ConnectionError("TMWebDriver master未运行,看tmwebdriver_sop后台启动一个TMWebDriver")
|
||||
|
||||
def get_all_sessions(self):
|
||||
if self.is_remote:
|
||||
|
||||
+29
-25
@@ -42,6 +42,10 @@ def get_system_prompt():
|
||||
prompt += get_global_memory()
|
||||
return prompt
|
||||
|
||||
# SDK:
|
||||
# agent = GenericAgent(); threading.Thread(target=agent.run, daemon=True).start()
|
||||
# output1_queue = agent.put_task(prompt1)
|
||||
# output2_queue = agent.put_task(prompt2)
|
||||
class GenericAgent:
|
||||
def __init__(self):
|
||||
os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True)
|
||||
@@ -50,12 +54,13 @@ class GenericAgent:
|
||||
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.show_mode = 'text'
|
||||
self.inc_out = False; self.verbose = True
|
||||
self.peer_hint = True
|
||||
self.force_non_stream = False
|
||||
logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}'
|
||||
self.log_path = os.path.join(script_dir, f'temp/model_responses/model_responses_{logid}.txt')
|
||||
self.load_llm_sessions()
|
||||
self.extra_sys_prompts = []
|
||||
|
||||
def load_llm_sessions(self):
|
||||
mykeys, changed = reload_mykeys()
|
||||
@@ -136,14 +141,13 @@ class GenericAgent:
|
||||
if raw_query is None:
|
||||
self.task_queue.task_done(); continue
|
||||
self.is_running = True
|
||||
if len(raw_query) > 1500:
|
||||
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() + getattr(self.llmclient.backend, 'extra_sys_prompt', '')
|
||||
sys_prompt = get_system_prompt() + '\n'.join(self.extra_sys_prompts) + getattr(self.llmclient.backend, 'extra_sys_prompt', '')
|
||||
if self.peer_hint: sys_prompt += f"\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\n"
|
||||
handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp'))
|
||||
if getattr(self, 'no_print', False): handler.print = lambda *a, **k: None
|
||||
@@ -158,7 +162,7 @@ class GenericAgent:
|
||||
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)
|
||||
max_turns=180, verbose=self.verbose, yield_info=True)
|
||||
try:
|
||||
full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = []
|
||||
for chunk in gen:
|
||||
@@ -187,13 +191,13 @@ class GenericAgent:
|
||||
self.task_queue.task_done()
|
||||
if self.handler is not None: self.handler.code_stop_signal.append(1)
|
||||
|
||||
GeneraticAgent = GenericAgent
|
||||
GeneraticAgent = GenericAgent
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--task', metavar='IODIR', help='一次性任务模式(文件IO)')
|
||||
parser.add_argument('--task', metavar='IODIR', help='一次性任务模式,先看subagent.md')
|
||||
parser.add_argument('--reflect', metavar='SCRIPT', help='反射模式:加载监控脚本,check()触发时发任务')
|
||||
parser.add_argument('--input', help='prompt')
|
||||
parser.add_argument('--llm_no', type=int, default=0)
|
||||
@@ -260,24 +264,24 @@ if __name__ == '__main__':
|
||||
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
|
||||
if task:
|
||||
print(f'[Reflect] triggered: {task[:80]}')
|
||||
dq = agent.put_task(task, source='reflect')
|
||||
try:
|
||||
while 'done' not in (item := dq.get(timeout=1200)): pass
|
||||
result = item['done']
|
||||
print(result)
|
||||
except Exception as e:
|
||||
if getattr(mod, 'ONCE', False): raise
|
||||
print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}'
|
||||
log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True)
|
||||
script_name = os.path.splitext(os.path.basename(args.reflect))[0]
|
||||
open(os.path.join(log_dir, f'{script_name}_{datetime.now():%Y-%m-%d}.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\n{result}\n\n')
|
||||
if (on_done := getattr(mod, 'on_done', None)):
|
||||
try: on_done(result)
|
||||
except Exception as e: print(f'[Reflect] on_done error: {e}')
|
||||
if getattr(mod, 'ONCE', False): print('[Reflect] ONCE=True, exiting.'); break
|
||||
time.sleep(getattr(mod, 'INTERVAL', 5))
|
||||
else:
|
||||
try: import readline
|
||||
except Exception: pass
|
||||
|
||||
+49
-25
@@ -29,38 +29,62 @@ 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-<your-anthropic-key>',
|
||||
'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 同级',
|
||||
'id': 'oai_chat',
|
||||
'name': 'OpenAI Chat Completions 协议',
|
||||
'desc': '官方直连或任意 OAI 兼容中转/网关,自填 apibase(回车=OpenAI 官方)',
|
||||
'type': 'native_oai',
|
||||
'template': {
|
||||
'name': 'gpt-native', 'apikey': 'sk-<your-openai-key>',
|
||||
'name': 'gpt-native', 'apikey': 'sk-<your-key>',
|
||||
'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.5',
|
||||
'api_mode': 'chat_completions', 'reasoning_effort': 'high',
|
||||
'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120,
|
||||
},
|
||||
'key_hint': '在 https://platform.openai.com/api-keys 获取',
|
||||
'key_hint': '官方在 https://platform.openai.com/api-keys 获取;中转站填其提供的 Key',
|
||||
'model_choices': ['gpt-5.5', 'gpt-5.4'],
|
||||
'extra_fields': [
|
||||
{'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.openai.com/v1'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'oai_responses',
|
||||
'name': 'OpenAI Responses 协议',
|
||||
'desc': 'Responses API(o 系列/GPT-5.5 推荐端点),官方或兼容网关,自填 apibase',
|
||||
'type': 'native_oai',
|
||||
'template': {
|
||||
'name': 'gpt-responses', 'apikey': 'sk-<your-key>',
|
||||
'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.5',
|
||||
'api_mode': 'responses', 'reasoning_effort': 'high',
|
||||
'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120,
|
||||
},
|
||||
'key_hint': '官方在 https://platform.openai.com/api-keys 获取;中转站填其提供的 Key',
|
||||
'model_choices': ['gpt-5.5', 'gpt-5.4'],
|
||||
'extra_fields': [
|
||||
{'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.openai.com/v1'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'claude_messages',
|
||||
'name': 'Claude Messages 协议',
|
||||
'desc': 'Anthropic 官方直连或任意 Claude 兼容中转,自填 apibase(回车=官方)',
|
||||
'type': 'native_claude',
|
||||
'template': {
|
||||
'name': 'anthropic-direct', 'apikey': 'sk-ant-<your-key>',
|
||||
'apibase': 'https://api.anthropic.com', 'model': 'claude-opus-4-7',
|
||||
'thinking_type': 'adaptive', 'max_tokens': 32768, 'temperature': 1,
|
||||
},
|
||||
'key_hint': '官方在 https://console.anthropic.com/ 获取;中转站填其提供的 Key',
|
||||
'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'],
|
||||
'extra_fields': [
|
||||
{'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.anthropic.com'},
|
||||
],
|
||||
},
|
||||
# ═══════════════════════════ 直连 API(按旗舰能力降序)═══════════════════════════
|
||||
{
|
||||
'id': 'deepseek',
|
||||
'name': 'DeepSeek (v4-Pro / Flash)',
|
||||
'desc': '最强开源模型,v4-Pro 旗舰 1M 上下文',
|
||||
'desc': '开源模型,v4-Pro 旗舰 1M 上下文',
|
||||
'type': 'native_oai',
|
||||
'template': {
|
||||
'name': 'deepseek', 'apikey': 'sk-<your-deepseek-key>',
|
||||
@@ -73,7 +97,7 @@ LLM_PROVIDERS = [
|
||||
{
|
||||
'id': 'kimi',
|
||||
'name': 'Kimi (k2.6 / k2.5) 双协议',
|
||||
'desc': '月之暗面,国内顶尖,支持 Anthropic 和 OAI 双协议',
|
||||
'desc': '月之暗面,支持 Anthropic 和 OAI 双协议',
|
||||
'type': 'native_claude',
|
||||
'template': {
|
||||
'name': 'kimi', 'apikey': 'sk-kimi-<your-key>',
|
||||
@@ -145,16 +169,16 @@ LLM_PROVIDERS = [
|
||||
},
|
||||
{
|
||||
'id': 'minimax',
|
||||
'name': 'MiniMax M2.7 (双协议)',
|
||||
'desc': 'MiniMax M2.7,支持 Anthropic 和 OpenAI 双协议',
|
||||
'name': 'MiniMax M3 (双协议)',
|
||||
'desc': 'MiniMax M3,支持 Anthropic 和 OpenAI 双协议',
|
||||
'type': 'native_claude',
|
||||
'template': {
|
||||
'name': 'minimax', 'apikey': 'eyJh...<your-minimax-key>',
|
||||
'apibase': 'https://api.minimaxi.com/anthropic',
|
||||
'model': 'MiniMax-M2.7', 'max_retries': 3,
|
||||
'model': 'MiniMax-M3', 'max_retries': 3,
|
||||
},
|
||||
'key_hint': '在 https://platform.minimaxi.com/user-center/basic-information 获取',
|
||||
'model_choices': ['MiniMax-M2.7', 'MiniMax-M2.5'],
|
||||
'model_choices': ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'],
|
||||
'extra_fields': [
|
||||
{
|
||||
'key': '_protocol', 'label': '选择 API 协议',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 MiB |
@@ -0,0 +1,577 @@
|
||||
#requires -version 5.1
|
||||
|
||||
<#!
|
||||
|
||||
GenericAgent one-click portable deployer for Windows.
|
||||
|
||||
|
||||
|
||||
Modes:
|
||||
|
||||
Default/Mainland: download GenericAgent.zip + uv + PortableGit from user's VPS, set China PyPI mirror.
|
||||
|
||||
GLOBAL=1: clone GenericAgent from GitHub; uv and PortableGit also come from GitHub releases; no PyPI mirror.
|
||||
|
||||
|
||||
|
||||
Portable components are installed under <InstallDir>\.portable:
|
||||
|
||||
uv, Python installed by uv, PortableGit.
|
||||
|
||||
#>
|
||||
|
||||
param(
|
||||
|
||||
[string]$InstallDir = "$env:USERPROFILE\GenericAgent",
|
||||
|
||||
[string]$PythonVersion = "3.12",
|
||||
|
||||
[switch]$Force
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
|
||||
|
||||
# Make Chinese output reliable in Windows PowerShell 5.1 and redirected logs.
|
||||
|
||||
try {
|
||||
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
} catch { }
|
||||
|
||||
|
||||
|
||||
$RepoUrl = "https://github.com/lsdefine/GenericAgent.git"
|
||||
|
||||
$VpsBase = "http://47.101.182.29:9000"
|
||||
|
||||
$GaZipUrl = "$VpsBase/files/GenericAgent.zip"
|
||||
|
||||
$UvUrl = "$VpsBase/uv/uv-x86_64-pc-windows-msvc.zip"
|
||||
|
||||
$GitUrl = "$VpsBase/files/PortableGit-2.54.0-64-bit.7z.exe"
|
||||
|
||||
$Deps = @("requests>=2.28", "beautifulsoup4>=4.12", "bottle>=0.12", "simple-websocket-server>=0.4", "streamlit>=1.28")
|
||||
|
||||
$MainlandIndex = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
|
||||
$GlobalMode = ($env:GLOBAL -eq "1")
|
||||
|
||||
if ($GlobalMode) {
|
||||
# GLOBAL=1: fetch everything from GitHub; no mainland endpoints involved.
|
||||
$UvUrl = "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip"
|
||||
$GitUrl = "https://github.com/git-for-windows/git/releases/download/v2.54.0.windows.1/PortableGit-2.54.0-64-bit.7z.exe"
|
||||
}
|
||||
|
||||
|
||||
|
||||
$GaDir = [IO.Path]::GetFullPath($InstallDir)
|
||||
|
||||
$PortableRoot = Join-Path $GaDir ".portable"
|
||||
|
||||
$Bin = Join-Path $PortableRoot "bin"
|
||||
|
||||
$Cache = Join-Path $PortableRoot "cache"
|
||||
|
||||
$Tools = Join-Path $PortableRoot "tools"
|
||||
|
||||
$UvZip = Join-Path $Cache "uv-x86_64-pc-windows-msvc.zip"
|
||||
|
||||
$GaZip = Join-Path $Cache "GenericAgent.zip"
|
||||
|
||||
$GitExeArchive = Join-Path $Cache "PortableGit-2.54.0-64-bit.7z.exe"
|
||||
|
||||
$UvExtract = Join-Path $Cache "uv-extract"
|
||||
|
||||
$GaExtract = Join-Path $Cache "ga-extract"
|
||||
|
||||
$GitDir = Join-Path $Tools "PortableGit"
|
||||
|
||||
$UvExe = Join-Path $Bin "uv.exe"
|
||||
|
||||
$GitExe = Join-Path $GitDir "bin\git.exe"
|
||||
|
||||
$EnvCmd = Join-Path $GaDir "env.cmd"
|
||||
|
||||
$EnvPs1 = Join-Path $GaDir "env.ps1"
|
||||
|
||||
|
||||
|
||||
function Say($m) { Write-Host "[ga-deploy] $m" -ForegroundColor Cyan }
|
||||
|
||||
function Ok($m) { Write-Host "[ok] $m" -ForegroundColor Green }
|
||||
|
||||
function Die($m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
|
||||
|
||||
function Invoke-Native([scriptblock]$Command) {
|
||||
|
||||
$prevEAP = $ErrorActionPreference
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
try { & $Command } finally { $ErrorActionPreference = $prevEAP }
|
||||
|
||||
return $LASTEXITCODE
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function Download-File($Url, $OutFile) {
|
||||
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $OutFile) | Out-Null
|
||||
|
||||
Say "Downloading $Url"
|
||||
|
||||
$wc = New-Object System.Net.WebClient
|
||||
|
||||
$wc.Headers.Add("User-Agent", "Mozilla/5.0 ga-deploy")
|
||||
|
||||
try { $wc.DownloadFile($Url, $OutFile) } finally { $wc.Dispose() }
|
||||
|
||||
if (!(Test-Path $OutFile) -or ((Get-Item $OutFile).Length -lt 1024)) { Die "Download failed: $Url" }
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function Expand-ZipClean($Zip, $Dest) {
|
||||
|
||||
if (Test-Path $Dest) { Remove-Item -Recurse -Force $Dest }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Dest | Out-Null
|
||||
|
||||
Expand-Archive -Path $Zip -DestinationPath $Dest -Force
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function Copy-DirectoryContents($Src, $Dst) {
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Dst | Out-Null
|
||||
|
||||
Get-ChildItem -LiteralPath $Src -Force | ForEach-Object {
|
||||
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $Dst -Recurse -Force
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
Say "Install dir: $GaDir"
|
||||
|
||||
Say "Mode: $(if ($GlobalMode) { 'GLOBAL=1 / GitHub clone' } else { 'Mainland / VPS zip' })"
|
||||
|
||||
|
||||
|
||||
if ((Test-Path $GaDir) -and $Force) { Remove-Item -Recurse -Force $GaDir }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $GaDir,$PortableRoot,$Bin,$Cache,$Tools | Out-Null
|
||||
|
||||
|
||||
|
||||
# uv (GitHub release in GLOBAL mode, user's VPS otherwise)
|
||||
|
||||
if (!(Test-Path $UvExe) -or $Force) {
|
||||
|
||||
Download-File $UvUrl $UvZip
|
||||
|
||||
Expand-ZipClean $UvZip $UvExtract
|
||||
|
||||
$foundUv = Get-ChildItem -Path $UvExtract -Recurse -Filter "uv.exe" | Select-Object -First 1
|
||||
|
||||
if (!$foundUv) { Die "uv.exe not found in archive" }
|
||||
|
||||
Copy-Item $foundUv.FullName $UvExe -Force
|
||||
|
||||
}
|
||||
|
||||
Ok "uv: $(& $UvExe --version)"
|
||||
|
||||
|
||||
|
||||
# Configure portable Python location. Mirror only in mainland mode.
|
||||
|
||||
$env:UV_PYTHON_INSTALL_DIR = Join-Path $PortableRoot "uv-python"
|
||||
|
||||
$env:UV_CACHE_DIR = Join-Path $PortableRoot "uv-cache"
|
||||
|
||||
if ($GlobalMode) {
|
||||
|
||||
Remove-Item Env:UV_DEFAULT_INDEX -ErrorAction SilentlyContinue
|
||||
|
||||
Remove-Item Env:PIP_INDEX_URL -ErrorAction SilentlyContinue
|
||||
|
||||
} else {
|
||||
|
||||
$env:UV_DEFAULT_INDEX = $MainlandIndex
|
||||
|
||||
$env:PIP_INDEX_URL = $MainlandIndex
|
||||
|
||||
}
|
||||
|
||||
$env:PATH = "$Bin;$env:PATH"
|
||||
|
||||
|
||||
|
||||
|
||||
# Workaround: uv creates minor-version symlinks (junctions) in UV_PYTHON_INSTALL_DIR.
|
||||
# If a previous interrupted install left a plain directory, uv fails with os error 4390.
|
||||
# Fix: remove any non-junction subdirectory so uv can recreate them cleanly.
|
||||
$uvPyDir = $env:UV_PYTHON_INSTALL_DIR
|
||||
if (Test-Path $uvPyDir) {
|
||||
Get-ChildItem -LiteralPath $uvPyDir -Directory | ForEach-Object {
|
||||
$attr = $_.Attributes
|
||||
if (($attr -band [IO.FileAttributes]::ReparsePoint) -eq 0) {
|
||||
Say "Removing stale non-junction dir: $($_.Name)"
|
||||
Remove-Item -LiteralPath $_.FullName -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Say "Installing Python $PythonVersion via uv"
|
||||
|
||||
$ec = Invoke-Native { & $UvExe python install $PythonVersion }
|
||||
|
||||
if ($ec -ne 0) { Die "uv python install failed" }
|
||||
|
||||
$PythonExe = (& $UvExe python find $PythonVersion).Trim()
|
||||
|
||||
if (!(Test-Path $PythonExe)) { Die "uv installed Python but python.exe was not found" }
|
||||
|
||||
Ok "Python: $(& $PythonExe --version)"
|
||||
|
||||
|
||||
|
||||
# PortableGit (GitHub release in GLOBAL mode, user's VPS otherwise). Needed for GLOBAL=1 and useful for user shell.
|
||||
|
||||
if (!(Test-Path $GitExe) -or $Force) {
|
||||
|
||||
Download-File $GitUrl $GitExeArchive
|
||||
|
||||
if (Test-Path $GitDir) { Remove-Item -Recurse -Force $GitDir }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $GitDir | Out-Null
|
||||
|
||||
Say "Extracting PortableGit"
|
||||
|
||||
$ec = Invoke-Native { & $GitExeArchive -y -o"$GitDir" | Out-Null }
|
||||
|
||||
if ($ec -ne 0) { Die "PortableGit extraction failed" }
|
||||
|
||||
}
|
||||
|
||||
if (!(Test-Path $GitExe)) { Die "git.exe missing: $GitExe" }
|
||||
|
||||
Ok "Git: $(& $GitExe --version)"
|
||||
|
||||
|
||||
|
||||
$PythonDir = Split-Path $PythonExe -Parent
|
||||
|
||||
$GitBin = Split-Path $GitExe -Parent
|
||||
|
||||
$GitUsrBin = Join-Path $GitDir "usr\bin"
|
||||
|
||||
$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;$env:PATH"
|
||||
|
||||
|
||||
|
||||
# Fetch/update GenericAgent source.
|
||||
|
||||
if ($GlobalMode) {
|
||||
|
||||
Say "Cloning GenericAgent from GitHub"
|
||||
|
||||
$items = @(Get-ChildItem -LiteralPath $GaDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ".portable" })
|
||||
|
||||
if ($items.Count -gt 0) {
|
||||
|
||||
if (!$Force) { Die "Install dir contains files. Re-run with -Force to replace source while preserving portable tools." }
|
||||
|
||||
$items | Remove-Item -Recurse -Force
|
||||
|
||||
}
|
||||
|
||||
$TmpClone = Join-Path $Cache "ga-clone"
|
||||
|
||||
if (Test-Path $TmpClone) { Remove-Item -Recurse -Force $TmpClone }
|
||||
|
||||
$ec = Invoke-Native { & $GitExe clone --depth 1 $RepoUrl $TmpClone }
|
||||
|
||||
if ($ec -ne 0) { Die "git clone failed" }
|
||||
|
||||
Copy-DirectoryContents $TmpClone $GaDir
|
||||
|
||||
Remove-Item -Recurse -Force $TmpClone
|
||||
|
||||
} else {
|
||||
|
||||
Say "Downloading GenericAgent package from VPS"
|
||||
|
||||
Download-File $GaZipUrl $GaZip
|
||||
|
||||
Expand-ZipClean $GaZip $GaExtract
|
||||
|
||||
$SrcDir = Join-Path $GaExtract "GenericAgent"
|
||||
|
||||
if (!(Test-Path $SrcDir)) { $SrcDir = $GaExtract }
|
||||
|
||||
$items = @(Get-ChildItem -LiteralPath $GaDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ".portable" })
|
||||
|
||||
if ($items.Count -gt 0) { $items | Remove-Item -Recurse -Force }
|
||||
|
||||
Copy-DirectoryContents $SrcDir $GaDir
|
||||
|
||||
}
|
||||
|
||||
Ok "GenericAgent source ready: $GaDir"
|
||||
|
||||
|
||||
|
||||
# Install basic dependencies and project in editable mode into portable Python.
|
||||
|
||||
Say "Installing GenericAgent dependencies via uv pip"
|
||||
|
||||
$installArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe)
|
||||
|
||||
if (!$GlobalMode) { $installArgs += @("--index-url", $MainlandIndex) }
|
||||
|
||||
$installArgs += $Deps
|
||||
|
||||
$ec = Invoke-Native { & $UvExe @installArgs }
|
||||
|
||||
if ($ec -ne 0) { Die "dependency install failed" }
|
||||
|
||||
|
||||
|
||||
if (Test-Path (Join-Path $GaDir "pyproject.toml")) {
|
||||
|
||||
$projectArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe)
|
||||
|
||||
if (!$GlobalMode) { $projectArgs += @("--index-url", $MainlandIndex) }
|
||||
|
||||
$projectArgs += @("-e", $GaDir)
|
||||
|
||||
$ec = Invoke-Native { & $UvExe @projectArgs }
|
||||
|
||||
if ($ec -ne 0) { Die "editable project install failed" }
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Try-install pywebview (optional UI). Failure is non-fatal.
|
||||
|
||||
Say "Attempting to install pywebview (optional, failure is OK)"
|
||||
|
||||
$webviewArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe)
|
||||
|
||||
if (!$GlobalMode) { $webviewArgs += @("--index-url", $MainlandIndex) }
|
||||
|
||||
$webviewArgs += @("pywebview>=4.0")
|
||||
|
||||
$ec = Invoke-Native { & $UvExe @webviewArgs 2>&1 | Out-Null }
|
||||
|
||||
if ($ec -ne 0) {
|
||||
|
||||
Write-Host "[warn] pywebview install failed. This is optional." -ForegroundColor Yellow
|
||||
|
||||
Write-Host " On Windows it usually works out of the box." -ForegroundColor Yellow
|
||||
|
||||
Write-Host " If needed later: uv pip install pywebview" -ForegroundColor Yellow
|
||||
|
||||
} else {
|
||||
|
||||
Ok "pywebview installed successfully"
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Activation scripts: portable paths are intentionally before system PATH.
|
||||
|
||||
if ($GlobalMode) {
|
||||
|
||||
@"
|
||||
|
||||
@echo off
|
||||
|
||||
set "PORTABLE_DEV_ROOT=$PortableRoot"
|
||||
|
||||
set "GENERICAGENT_HOME=$GaDir"
|
||||
|
||||
set "UV_PYTHON_INSTALL_DIR=$PortableRoot\uv-python"
|
||||
|
||||
set "UV_CACHE_DIR=$PortableRoot\uv-cache"
|
||||
|
||||
set "PATH=$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;%PATH%"
|
||||
|
||||
echo Activated GenericAgent portable env: %GENERICAGENT_HOME%
|
||||
|
||||
"@ | Set-Content -Path $EnvCmd -Encoding ASCII
|
||||
|
||||
|
||||
|
||||
@"
|
||||
|
||||
`$env:PORTABLE_DEV_ROOT = "$PortableRoot"
|
||||
|
||||
`$env:GENERICAGENT_HOME = "$GaDir"
|
||||
|
||||
`$env:UV_PYTHON_INSTALL_DIR = "$PortableRoot\uv-python"
|
||||
|
||||
`$env:UV_CACHE_DIR = "$PortableRoot\uv-cache"
|
||||
|
||||
`$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;`$env:PATH"
|
||||
|
||||
Write-Host "Activated GenericAgent portable env: `$env:GENERICAGENT_HOME" -ForegroundColor Green
|
||||
|
||||
"@ | Set-Content -Path $EnvPs1 -Encoding UTF8
|
||||
|
||||
} else {
|
||||
|
||||
@"
|
||||
|
||||
@echo off
|
||||
|
||||
set "PORTABLE_DEV_ROOT=$PortableRoot"
|
||||
|
||||
set "GENERICAGENT_HOME=$GaDir"
|
||||
|
||||
set "UV_PYTHON_INSTALL_DIR=$PortableRoot\uv-python"
|
||||
|
||||
set "UV_CACHE_DIR=$PortableRoot\uv-cache"
|
||||
|
||||
set "UV_DEFAULT_INDEX=$MainlandIndex"
|
||||
|
||||
set "PIP_INDEX_URL=$MainlandIndex"
|
||||
|
||||
set "PATH=$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;%PATH%"
|
||||
|
||||
echo Activated GenericAgent portable env: %GENERICAGENT_HOME%
|
||||
|
||||
"@ | Set-Content -Path $EnvCmd -Encoding ASCII
|
||||
|
||||
|
||||
|
||||
@"
|
||||
|
||||
`$env:PORTABLE_DEV_ROOT = "$PortableRoot"
|
||||
|
||||
`$env:GENERICAGENT_HOME = "$GaDir"
|
||||
|
||||
`$env:UV_PYTHON_INSTALL_DIR = "$PortableRoot\uv-python"
|
||||
|
||||
`$env:UV_CACHE_DIR = "$PortableRoot\uv-cache"
|
||||
|
||||
`$env:UV_DEFAULT_INDEX = "$MainlandIndex"
|
||||
|
||||
`$env:PIP_INDEX_URL = "$MainlandIndex"
|
||||
|
||||
`$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;`$env:PATH"
|
||||
|
||||
Write-Host "Activated GenericAgent portable env: `$env:GENERICAGENT_HOME" -ForegroundColor Green
|
||||
|
||||
"@ | Set-Content -Path $EnvPs1 -Encoding UTF8
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
Ok "Verification:"
|
||||
|
||||
& $UvExe --version
|
||||
|
||||
& $PythonExe --version
|
||||
|
||||
& $GitExe --version
|
||||
|
||||
& $PythonExe -c "import requests, bs4, bottle; print('deps ok')"
|
||||
|
||||
Write-Host ""
|
||||
|
||||
|
||||
|
||||
# Copy mykey template if mykey.py does not exist (GLOBAL mode only)
|
||||
|
||||
$MykeyDst = Join-Path $GaDir "mykey.py"
|
||||
|
||||
if ($GlobalMode -and !(Test-Path $MykeyDst)) {
|
||||
|
||||
$MykeyTpl = Join-Path $GaDir "mykey_template_en.py"
|
||||
|
||||
if (Test-Path $MykeyTpl) {
|
||||
|
||||
Copy-Item $MykeyTpl $MykeyDst
|
||||
|
||||
Ok "Copied mykey_template_en.py -> mykey.py"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Final banner
|
||||
|
||||
Write-Host ""
|
||||
|
||||
if ($GlobalMode) {
|
||||
|
||||
Write-Host @"
|
||||
|
||||
╔═══════════════════════════════════════════════╗
|
||||
|
||||
║ ✅ GenericAgent installed successfully! ║
|
||||
|
||||
╠═══════════════════════════════════════════════╣
|
||||
|
||||
║ 📁 Location: $GaDir
|
||||
|
||||
║ 🔑 Config: edit mykey.py (copied from template)
|
||||
|
||||
║ 🚀 Launch: ga tui / ga launch / ga hub
|
||||
|
||||
╚═══════════════════════════════════════════════╝
|
||||
|
||||
"@
|
||||
|
||||
} else {
|
||||
|
||||
Write-Host @"
|
||||
|
||||
╔═══════════════════════════════════════════════╗
|
||||
|
||||
║ [OK] GenericAgent 安装完成! ║
|
||||
|
||||
╠═══════════════════════════════════════════════╣
|
||||
|
||||
║ 安装目录: $GaDir
|
||||
|
||||
║ 配置密钥: ga configure
|
||||
|
||||
║ 启动: ga tui / ga launch / ga hub
|
||||
|
||||
╚═══════════════════════════════════════════════╝
|
||||
|
||||
"@
|
||||
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
Write-Host " Activate env: cmd.exe → call `"$EnvCmd`" | PowerShell → . `"$EnvPs1`""
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# GenericAgent one-click portable deployer for macOS/Linux.
|
||||
# Modes:
|
||||
# Default/Mainland: download GenericAgent.zip + uv from user's VPS, set China PyPI mirror.
|
||||
# GLOBAL=1: clone GenericAgent from GitHub; uv also from GitHub releases; no PyPI mirror.
|
||||
# Portable components are installed under <INSTALL_DIR>/.portable:
|
||||
# uv, Python installed by uv. On macOS/Linux git is expected from system package manager.
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/GenericAgent}"
|
||||
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
|
||||
FORCE="${FORCE:-0}"
|
||||
GLOBAL="${GLOBAL:-0}"
|
||||
|
||||
REPO_URL="https://github.com/lsdefine/GenericAgent.git"
|
||||
VPS_BASE="http://47.101.182.29:9000"
|
||||
GA_ZIP_URL="$VPS_BASE/files/GenericAgent.zip"
|
||||
MAINLAND_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
DEPS=("requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "streamlit>=1.28")
|
||||
|
||||
say(){ printf '\033[36m[ga-deploy]\033[0m %s\n' "$*"; }
|
||||
ok(){ printf '\033[32m[ok]\033[0m %s\n' "$*"; }
|
||||
die(){ printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
usage(){ cat <<'EOF'
|
||||
Usage:
|
||||
bash install_portable_env.sh
|
||||
INSTALL_DIR="$HOME/GenericAgent" PYTHON_VERSION=3.12 FORCE=1 bash install_portable_env.sh
|
||||
GLOBAL=1 bash install_portable_env.sh
|
||||
|
||||
Environment variables:
|
||||
INSTALL_DIR Install GenericAgent here. Default: ~/GenericAgent
|
||||
PYTHON_VERSION Python version installed by uv. Default: 3.12
|
||||
FORCE=1 Replace existing source files while preserving/reinstalling portable tools.
|
||||
GLOBAL=1 Clone from GitHub directly and do not set China PyPI mirror.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage; exit 0; fi
|
||||
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
case "$OS:$ARCH" in
|
||||
Darwin:x86_64) uv_file="uv-x86_64-apple-darwin.tar.gz" ;;
|
||||
Darwin:arm64) uv_file="uv-aarch64-apple-darwin.tar.gz" ;;
|
||||
Linux:x86_64) uv_file="uv-x86_64-unknown-linux-gnu.tar.gz" ;;
|
||||
Linux:aarch64|Linux:arm64) uv_file="uv-aarch64-unknown-linux-gnu.tar.gz" ;;
|
||||
*) die "Unsupported platform: $OS $ARCH" ;;
|
||||
esac
|
||||
|
||||
GA_DIR="${INSTALL_DIR/#\~/$HOME}"
|
||||
case "$GA_DIR" in
|
||||
/*) ;;
|
||||
*) GA_DIR="$PWD/$GA_DIR" ;;
|
||||
esac
|
||||
mkdir -p "$GA_DIR"
|
||||
GA_DIR="$(cd "$GA_DIR" && pwd -P)"
|
||||
PORTABLE_ROOT="$GA_DIR/.portable"
|
||||
BIN="$PORTABLE_ROOT/bin"
|
||||
CACHE="$PORTABLE_ROOT/cache"
|
||||
UV_TGZ="$CACHE/$uv_file"
|
||||
GA_ZIP="$CACHE/GenericAgent.zip"
|
||||
UV_EXTRACT="$CACHE/uv-extract"
|
||||
GA_EXTRACT="$CACHE/ga-extract"
|
||||
UV_EXE="$BIN/uv"
|
||||
ENV_SH="$GA_DIR/env.sh"
|
||||
|
||||
mkdir -p "$GA_DIR" "$PORTABLE_ROOT" "$BIN" "$CACHE"
|
||||
|
||||
say "Install dir: $GA_DIR"
|
||||
if [[ "$GLOBAL" == "1" ]]; then say "Mode: GLOBAL=1 / GitHub clone"; else say "Mode: Mainland / VPS zip"; fi
|
||||
|
||||
if [[ "$FORCE" == "1" ]]; then
|
||||
# Preserve .portable if present; remove source files later before deploying.
|
||||
:
|
||||
fi
|
||||
|
||||
download(){
|
||||
local url="$1" out="$2"
|
||||
mkdir -p "$(dirname "$out")"
|
||||
say "Downloading $url"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fL --retry 3 -A "ga-deploy" -o "$out" "$url"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -O "$out" "$url"
|
||||
else
|
||||
die "curl or wget is required"
|
||||
fi
|
||||
[[ -s "$out" ]] || die "Download failed: $url"
|
||||
}
|
||||
|
||||
extract_tgz_clean(){
|
||||
local tgz="$1" dest="$2"
|
||||
rm -rf "$dest"; mkdir -p "$dest"
|
||||
tar -xzf "$tgz" -C "$dest"
|
||||
}
|
||||
|
||||
extract_zip_clean(){
|
||||
local zip="$1" dest="$2"
|
||||
rm -rf "$dest"; mkdir -p "$dest"
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -q "$zip" -d "$dest"
|
||||
else
|
||||
python3 - "$zip" "$dest" <<'PY'
|
||||
import sys, zipfile
|
||||
with zipfile.ZipFile(sys.argv[1]) as z:
|
||||
z.extractall(sys.argv[2])
|
||||
PY
|
||||
fi
|
||||
}
|
||||
|
||||
copy_contents(){
|
||||
local src="$1" dst="$2"
|
||||
mkdir -p "$dst"
|
||||
(cd "$src" && tar -cf - .) | (cd "$dst" && tar -xf -)
|
||||
}
|
||||
|
||||
remove_source_files(){
|
||||
shopt -s dotglob nullglob
|
||||
for p in "$GA_DIR"/*; do
|
||||
[[ "$(basename "$p")" == ".portable" ]] && continue
|
||||
rm -rf "$p"
|
||||
done
|
||||
shopt -u dotglob nullglob
|
||||
}
|
||||
|
||||
# uv: GitHub release in GLOBAL mode, user's VPS otherwise
|
||||
if [[ ! -x "$UV_EXE" || "$FORCE" == "1" ]]; then
|
||||
if [[ "$GLOBAL" == "1" ]]; then
|
||||
download "https://github.com/astral-sh/uv/releases/latest/download/$uv_file" "$UV_TGZ"
|
||||
else
|
||||
download "$VPS_BASE/uv/$uv_file" "$UV_TGZ"
|
||||
fi
|
||||
extract_tgz_clean "$UV_TGZ" "$UV_EXTRACT"
|
||||
found_uv="$(find "$UV_EXTRACT" -type f -name uv | head -n 1 || true)"
|
||||
[[ -n "$found_uv" ]] || die "uv not found in archive"
|
||||
cp "$found_uv" "$UV_EXE"
|
||||
chmod +x "$UV_EXE"
|
||||
fi
|
||||
ok "uv: $($UV_EXE --version)"
|
||||
|
||||
export UV_PYTHON_INSTALL_DIR="$PORTABLE_ROOT/uv-python"
|
||||
export UV_CACHE_DIR="$PORTABLE_ROOT/uv-cache"
|
||||
if [[ "$GLOBAL" == "1" ]]; then
|
||||
unset UV_DEFAULT_INDEX PIP_INDEX_URL
|
||||
else
|
||||
export UV_DEFAULT_INDEX="$MAINLAND_INDEX"
|
||||
export PIP_INDEX_URL="$MAINLAND_INDEX"
|
||||
fi
|
||||
export PATH="$BIN:$PATH"
|
||||
|
||||
say "Installing Python $PYTHON_VERSION via uv"
|
||||
"$UV_EXE" python install "$PYTHON_VERSION"
|
||||
PYTHON_EXE="$($UV_EXE python find "$PYTHON_VERSION")"
|
||||
[[ -x "$PYTHON_EXE" ]] || die "uv installed Python but executable was not found"
|
||||
ok "Python: $($PYTHON_EXE --version)"
|
||||
PYTHON_DIR="$(dirname "$PYTHON_EXE")"
|
||||
export PATH="$BIN:$PYTHON_DIR:$PATH"
|
||||
|
||||
# git: macOS/Linux use system git. In mainland mode it is not required for source fetch.
|
||||
GIT_EXE=""
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
GIT_EXE="$(command -v git)"
|
||||
ok "git: $($GIT_EXE --version)"
|
||||
elif [[ "$GLOBAL" == "1" ]]; then
|
||||
die "GLOBAL=1 requires git. Install git with your system package manager, then rerun."
|
||||
else
|
||||
say "git not found; continuing because mainland mode uses VPS zip. Install git later if needed."
|
||||
fi
|
||||
|
||||
# Fetch/update GenericAgent source.
|
||||
if [[ "$GLOBAL" == "1" ]]; then
|
||||
say "Cloning GenericAgent from GitHub"
|
||||
if [[ -n "$(find "$GA_DIR" -mindepth 1 -maxdepth 1 ! -name .portable -print -quit)" ]]; then
|
||||
[[ "$FORCE" == "1" ]] || die "Install dir contains files. Re-run with FORCE=1 to replace source while preserving portable tools."
|
||||
remove_source_files
|
||||
fi
|
||||
TMP_CLONE="$CACHE/ga-clone"
|
||||
rm -rf "$TMP_CLONE"
|
||||
"$GIT_EXE" clone --depth 1 "$REPO_URL" "$TMP_CLONE"
|
||||
copy_contents "$TMP_CLONE" "$GA_DIR"
|
||||
rm -rf "$TMP_CLONE"
|
||||
else
|
||||
say "Downloading GenericAgent package from VPS"
|
||||
download "$GA_ZIP_URL" "$GA_ZIP"
|
||||
extract_zip_clean "$GA_ZIP" "$GA_EXTRACT"
|
||||
SRC_DIR="$GA_EXTRACT/GenericAgent"
|
||||
[[ -d "$SRC_DIR" ]] || SRC_DIR="$GA_EXTRACT"
|
||||
remove_source_files
|
||||
copy_contents "$SRC_DIR" "$GA_DIR"
|
||||
fi
|
||||
ok "GenericAgent source ready: $GA_DIR"
|
||||
|
||||
# Install basic dependencies and project in editable mode into portable Python.
|
||||
say "Installing GenericAgent dependencies via uv pip"
|
||||
install_args=(pip install --python "$PYTHON_EXE" --break-system-packages)
|
||||
if [[ "$GLOBAL" != "1" ]]; then install_args+=(--index-url "$MAINLAND_INDEX"); fi
|
||||
install_args+=("${DEPS[@]}")
|
||||
"$UV_EXE" "${install_args[@]}"
|
||||
|
||||
if [[ -f "$GA_DIR/pyproject.toml" ]]; then
|
||||
project_args=(pip install --python "$PYTHON_EXE" --break-system-packages)
|
||||
if [[ "$GLOBAL" != "1" ]]; then project_args+=(--index-url "$MAINLAND_INDEX"); fi
|
||||
project_args+=(-e "$GA_DIR")
|
||||
"$UV_EXE" "${project_args[@]}"
|
||||
fi
|
||||
|
||||
# Try-install pywebview (optional UI). Failure is non-fatal.
|
||||
say "Attempting to install pywebview (optional, failure is OK)"
|
||||
webview_args=(pip install --python "$PYTHON_EXE" --break-system-packages)
|
||||
if [[ "$GLOBAL" != "1" ]]; then webview_args+=(--index-url "$MAINLAND_INDEX"); fi
|
||||
webview_args+=("pywebview>=4.0")
|
||||
if "$UV_EXE" "${webview_args[@]}" 2>/dev/null; then
|
||||
ok "pywebview installed successfully"
|
||||
else
|
||||
printf '\033[33m[warn]\033[0m pywebview install failed. This is optional.\n'
|
||||
printf ' On Linux, pywebview requires system GTK/WebKit libraries.\n'
|
||||
printf ' Install them first, e.g.:\n'
|
||||
printf ' Debian/Ubuntu: sudo apt install python3-gi gir1.2-webkit2-4.1 libgirepository1.0-dev\n'
|
||||
printf ' Fedora: sudo dnf install python3-gobject webkit2gtk4.1\n'
|
||||
printf ' macOS: usually works out of the box (uses PyObjC)\n'
|
||||
printf ' Then retry: uv pip install pywebview\n'
|
||||
fi
|
||||
|
||||
# Activation script: portable paths are intentionally before system PATH.
|
||||
if [[ "$GLOBAL" == "1" ]]; then
|
||||
cat > "$ENV_SH" <<EOF
|
||||
export PORTABLE_DEV_ROOT="$PORTABLE_ROOT"
|
||||
export GENERICAGENT_HOME="$GA_DIR"
|
||||
export UV_PYTHON_INSTALL_DIR="$PORTABLE_ROOT/uv-python"
|
||||
export UV_CACHE_DIR="$PORTABLE_ROOT/uv-cache"
|
||||
export PATH="$BIN:$PYTHON_DIR:\$PATH"
|
||||
echo "Activated GenericAgent portable env: \$GENERICAGENT_HOME"
|
||||
EOF
|
||||
else
|
||||
cat > "$ENV_SH" <<EOF
|
||||
export PORTABLE_DEV_ROOT="$PORTABLE_ROOT"
|
||||
export GENERICAGENT_HOME="$GA_DIR"
|
||||
export UV_PYTHON_INSTALL_DIR="$PORTABLE_ROOT/uv-python"
|
||||
export UV_CACHE_DIR="$PORTABLE_ROOT/uv-cache"
|
||||
export UV_DEFAULT_INDEX="$MAINLAND_INDEX"
|
||||
export PIP_INDEX_URL="$MAINLAND_INDEX"
|
||||
export PATH="$BIN:$PYTHON_DIR:\$PATH"
|
||||
echo "Activated GenericAgent portable env: \$GENERICAGENT_HOME"
|
||||
EOF
|
||||
fi
|
||||
|
||||
ok "Verification:"
|
||||
"$UV_EXE" --version
|
||||
"$PYTHON_EXE" --version
|
||||
if [[ -n "$GIT_EXE" ]]; then "$GIT_EXE" --version; fi
|
||||
"$PYTHON_EXE" -c "import requests, bs4, bottle; print('deps ok')"
|
||||
|
||||
# Copy mykey template if mykey.py does not exist (GLOBAL mode only)
|
||||
MYKEY_DST="$GA_DIR/mykey.py"
|
||||
if [[ "$GLOBAL" == "1" && ! -f "$MYKEY_DST" ]]; then
|
||||
MYKEY_TPL="$GA_DIR/mykey_template_en.py"
|
||||
if [[ -f "$MYKEY_TPL" ]]; then
|
||||
cp "$MYKEY_TPL" "$MYKEY_DST"
|
||||
ok "Copied mykey_template_en.py -> mykey.py"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Final banner
|
||||
echo ""
|
||||
if [[ "$GLOBAL" == "1" ]]; then
|
||||
cat <<EOF
|
||||
╔═══════════════════════════════════════════════╗
|
||||
║ ✅ GenericAgent installed successfully! ║
|
||||
╠═══════════════════════════════════════════════╣
|
||||
║ 📁 Location: $GA_DIR
|
||||
║ 🔑 Config: edit mykey.py (copied from template)
|
||||
║ 🚀 Launch: ga tui / ga launch / ga hub
|
||||
╚═══════════════════════════════════════════════╝
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
╔═══════════════════════════════════════════════╗
|
||||
║ ✅ GenericAgent 安装完成! ║
|
||||
╠═══════════════════════════════════════════════╣
|
||||
║ 📁 安装目录: $GA_DIR
|
||||
║ 🔑 配置密钥: ga configure
|
||||
║ 🚀 启动: ga tui / ga launch / ga hub
|
||||
╚═══════════════════════════════════════════════╝
|
||||
EOF
|
||||
fi
|
||||
echo ""
|
||||
ok "Activate env: source \"$ENV_SH\""
|
||||
@@ -2,7 +2,7 @@
|
||||
需要时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
|
||||
L3: memory_cleanup_sop(记忆整理) | 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/ 历史会话
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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
|
||||
L3: memory_cleanup_sop(memory cleanup) | 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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
@@ -1,4 +1,5 @@
|
||||
// Disable alert/confirm/prompt to prevent page JS from blocking extension
|
||||
try{delete Navigator.prototype.webdriver;delete navigator.webdriver}catch(e){}
|
||||
(function() {
|
||||
const _log = console.log.bind(console);
|
||||
function toast(type, msg) {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
[{"role":"user","content":[{"type":"text","text":"code_run个helloworld,根据结果简单评价你的工具配置"}]},{"role":"assistant","content":[{"type":"text","text":"\n\n<summary>用户要求code_run helloworld并评价工具配置</summary>\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<history>\n\n[USER]: code_run个helloworld,根据结果简单评价你的工具配置\n</history>\nCurrent turn: 1\n"}]},{"role":"assistant","content":[{"type":"text","text":"\n\n<summary>code_run helloworld成功,评价工具配置</summary>\n\n工具配置没问题。`code_run` 返回了结构化的 JSON(`status`、`stdout`、`exit_code`),执行链路通畅,cwd 默认落在 temp 目录。\n\n刚才还验证了 `inline_eval` 能正确访问 agent 内部状态并导出 history,`file_patch` 也能精准改文件。整套工具链从代码执行、文件读写、到 agent 内省都是通的。"}]}]
|
||||
@@ -16,7 +16,6 @@
|
||||
"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": {
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""@ file completion — shared UI-less logic for tui_v2 / tui_v3.
|
||||
|
||||
File index (os.scandir, cached per root) + fuzzy match + @token detection +
|
||||
insert text. No UI deps; each front-end renders candidates its own way and
|
||||
calls candidates_for(query, root). Index root is the front-end's choice
|
||||
(session workspace, else CWD). Submit-time: completion-only does NOT read
|
||||
content, but absolutize_mentions() rewrites @relative → @absolute so the
|
||||
agent's file_read (relative to its own cwd) can locate the file. The
|
||||
content-injecting auto-read variant lives in
|
||||
temp/plan_v2_at_mention/autoread_version.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
# ---------------------------------------------------------------- index
|
||||
|
||||
_IGNORE_DIRS = {
|
||||
".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
|
||||
".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build",
|
||||
".next", ".idea", ".vscode", "target", ".cache", ".eggs",
|
||||
"model_responses", # GA 会话日志(上千个 .txt),未绑时根=temp 会淹没 @ 候选
|
||||
}
|
||||
_IGNORE_EXT = {".pyc", ".pyo", ".so", ".o", ".class", ".lock", ".dll", ".exe"}
|
||||
_MAX_FILES = 50_000 # 超大目录宁缺毋卡:到上限就停
|
||||
|
||||
|
||||
def scan_files(root: str, max_files: int = _MAX_FILES) -> list[str]:
|
||||
"""Collect relative file paths under root, '/'-normalized.
|
||||
|
||||
os.scandir over os.walk: one syscall yields is_dir without an extra
|
||||
stat per entry. Dotted dirs are skipped wholesale (.git, .venv...).
|
||||
"""
|
||||
out: list[str] = []
|
||||
stack = [root]
|
||||
while stack and len(out) < max_files:
|
||||
d = stack.pop()
|
||||
try:
|
||||
with os.scandir(d) as it:
|
||||
for e in it:
|
||||
try:
|
||||
if e.is_dir(follow_symlinks=False):
|
||||
if e.name not in _IGNORE_DIRS and not e.name.startswith("."):
|
||||
stack.append(e.path)
|
||||
elif e.is_file(follow_symlinks=False):
|
||||
if os.path.splitext(e.name)[1].lower() not in _IGNORE_EXT:
|
||||
rel = os.path.relpath(e.path, root).replace("\\", "/")
|
||||
out.append(rel)
|
||||
if len(out) >= max_files:
|
||||
return out
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
class FileIndexCache:
|
||||
"""Per-root background file index. warm() is idempotent-cheap: a
|
||||
rebuild is only started when none is in flight."""
|
||||
|
||||
def __init__(self, root: str):
|
||||
self.root = root
|
||||
self._files: list[str] = []
|
||||
self._lock = threading.Lock()
|
||||
self._building = False
|
||||
self.ready = threading.Event()
|
||||
|
||||
def warm(self) -> None:
|
||||
with self._lock:
|
||||
if self._building:
|
||||
return
|
||||
self._building = True
|
||||
|
||||
def _build():
|
||||
try:
|
||||
files = scan_files(self.root)
|
||||
with self._lock:
|
||||
self._files = files
|
||||
self.ready.set()
|
||||
finally:
|
||||
with self._lock:
|
||||
self._building = False
|
||||
|
||||
threading.Thread(target=_build, name="ga-at-index", daemon=True).start()
|
||||
|
||||
def snapshot(self) -> list[str]:
|
||||
with self._lock:
|
||||
return self._files
|
||||
|
||||
|
||||
_indexes: dict[str, FileIndexCache] = {}
|
||||
_indexes_lk = threading.Lock()
|
||||
|
||||
|
||||
def get_index(root: str) -> FileIndexCache:
|
||||
key = os.path.normcase(os.path.realpath(root or os.getcwd()))
|
||||
with _indexes_lk:
|
||||
idx = _indexes.get(key)
|
||||
if idx is None:
|
||||
idx = _indexes[key] = FileIndexCache(root)
|
||||
return idx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fuzzy
|
||||
|
||||
def _subseq_score(q: str, path: str):
|
||||
"""Subsequence match score (higher = better), None when q doesn't
|
||||
fully appear in order. Contiguous runs dominate (fzf-style): scattered
|
||||
one-char hits across a long path must not beat a tight cluster.
|
||||
Word-boundary hits and basename substring add on top; ties broken by
|
||||
caller on shorter path."""
|
||||
if not q:
|
||||
return 0
|
||||
score, qi, prev_hit = 0, 0, -2
|
||||
for pi, ch in enumerate(path):
|
||||
if qi < len(q) and ch == q[qi]:
|
||||
score += 1
|
||||
if pi == prev_hit + 1:
|
||||
score += 2 # contiguous run: the dominant signal
|
||||
if pi == 0 or path[pi - 1] in "/\\_-. ":
|
||||
score += 3
|
||||
prev_hit = pi
|
||||
qi += 1
|
||||
if qi < len(q):
|
||||
return None
|
||||
base = path.rsplit("/", 1)[-1]
|
||||
if q in base:
|
||||
score += 8
|
||||
elif q in path:
|
||||
score += 4
|
||||
return score
|
||||
|
||||
|
||||
def fuzzy_rank(query: str, files: list[str], limit: int = 10) -> list[str]:
|
||||
q = query.lower()
|
||||
if not q:
|
||||
# bare `@`: surface shallow paths first for discoverability
|
||||
return sorted(files, key=lambda f: (f.count("/"), f))[:limit]
|
||||
scored = []
|
||||
for f in files:
|
||||
s = _subseq_score(q, f.lower())
|
||||
if s is not None:
|
||||
scored.append((s, f))
|
||||
scored.sort(key=lambda x: (-x[0], len(x[1]), x[1]))
|
||||
return [f for _, f in scored[:limit]]
|
||||
|
||||
|
||||
# ------------------------------------------------------- edit-time token
|
||||
|
||||
# `(?:^|\s)@` 前置:@ 前必须是行首或空白 → 邮箱/代码里的 a@b 不触发。
|
||||
# 字符集含路径分隔符与 ~ :,\w 在 unicode 下覆盖中文文件名。
|
||||
_AT_TOKEN_RE = re.compile(r"(?:^|\s)(@[\w\-./\\~:]*)$", re.UNICODE)
|
||||
|
||||
|
||||
def find_at_token(line_before_cursor: str):
|
||||
"""Return (query, at_pos) when the cursor sits in an @token being
|
||||
typed on this line, else None. at_pos is the index of '@'."""
|
||||
m = _AT_TOKEN_RE.search(line_before_cursor)
|
||||
if not m:
|
||||
return None
|
||||
tok = m.group(1)
|
||||
return tok[1:], m.start(1)
|
||||
|
||||
|
||||
def format_pick(path: str) -> str:
|
||||
"""`@path` insert text; dirs get no trailing space (keep completing next
|
||||
level), files get one (close token). Spaces → quoted."""
|
||||
trailing = '' if path.endswith(('/', '\\')) else ' '
|
||||
return f'@"{path}"{trailing}' if ' ' in path else f'@{path}{trailing}'
|
||||
|
||||
|
||||
# --- path-like completion: an explicit-path @token (~/ / ./ ../ or C:\) goes
|
||||
# to live directory completion instead of index fuzzy — this is how absolute
|
||||
# paths outside the index root get completed level by level (claude-code parity).
|
||||
|
||||
def is_path_like(token: str) -> bool:
|
||||
if token in ('~', '.', '..'):
|
||||
return True
|
||||
if token.startswith(('~/', '~\\', './', '.\\', '../', '..\\', '/', '\\')):
|
||||
return True
|
||||
return len(token) >= 3 and token[0].isalpha() and token[1] == ':' and token[2] in '/\\'
|
||||
|
||||
|
||||
def path_completions(token: str, root: str, limit: int = 15) -> list[str]:
|
||||
"""readdir the real dir of a path-like token, prefix-match, dirs first.
|
||||
`~` expanded, relative → root, absolute as-is; candidates keep the token's
|
||||
spelling, dirs carry a trailing '/'."""
|
||||
sep = max(token.rfind('/'), token.rfind('\\'))
|
||||
if sep >= 0:
|
||||
dir_part, prefix = token[:sep + 1], token[sep + 1:]
|
||||
elif token in ('~', '.', '..'):
|
||||
dir_part, prefix = token.rstrip('/\\') + '/', ''
|
||||
else:
|
||||
return []
|
||||
exp = os.path.expanduser(dir_part)
|
||||
real_dir = exp if os.path.isabs(exp) else os.path.join(root, exp)
|
||||
try:
|
||||
with os.scandir(real_dir) as it:
|
||||
entries = list(it)
|
||||
except OSError:
|
||||
return []
|
||||
pl = prefix.lower()
|
||||
rows = []
|
||||
for e in entries:
|
||||
nm = e.name
|
||||
if pl and not nm.lower().startswith(pl):
|
||||
continue
|
||||
if nm.startswith('.') and not prefix.startswith('.'): # 隐藏项需显式 . 才出
|
||||
continue
|
||||
try:
|
||||
is_dir = e.is_dir()
|
||||
except OSError:
|
||||
is_dir = False
|
||||
rows.append((not is_dir, nm.lower(), dir_part + nm + ('/' if is_dir else '')))
|
||||
rows.sort(key=lambda r: (r[0], r[1])) # 目录优先 + 字母序
|
||||
return [d for _, _, d in rows[:limit]]
|
||||
|
||||
|
||||
def candidates_for(query: str, root: str, limit: int = 15, absolute: bool = False) -> list[str]:
|
||||
"""@token candidates: path-like → directory completion, else index fuzzy.
|
||||
Single dispatch point shared by both front-ends. `absolute=True` returns
|
||||
fuzzy hits as absolute paths (front-end shows full path when no workspace
|
||||
is bound, since the relative root isn't obvious to the user)."""
|
||||
if is_path_like(query):
|
||||
return path_completions(query, root, limit)
|
||||
idx = get_index(root)
|
||||
files = idx.snapshot()
|
||||
if not files:
|
||||
idx.warm() # 惰性兜底:该根还没建索引 → 后台建(本次可能空,下次有)
|
||||
res = fuzzy_rank(query, files, limit) if files else []
|
||||
if absolute:
|
||||
res = [os.path.normpath(os.path.join(root, c)) for c in res]
|
||||
return res
|
||||
|
||||
|
||||
# ------------------------------------------------------ submit-time absolutize
|
||||
# A fuzzy candidate inserts a path relative to the @ root (workspace/CWD), but
|
||||
# the agent's file_read resolves relative to its own ./temp cwd — so a bare
|
||||
# `@frontends/x.py` won't be found. At submit we rewrite each @mention naming a
|
||||
# real file to an absolute path; display keeps the short form. Still no content
|
||||
# read — this only completes the path so the agent can locate it.
|
||||
|
||||
_AT_ABS_RE = re.compile(r'(^|\s)@("([^"]+)"|([\w\-./\\~:#]+))', re.UNICODE)
|
||||
_LINE_SUFFIX_RE = re.compile(r'(#L\d+(?:-\d+)?)$')
|
||||
|
||||
|
||||
def absolutize_mentions(text: str, root: str) -> str:
|
||||
"""@relative → @absolute (root-resolved, ~ expanded, quoted if it gains a
|
||||
space), `#Lx-y` suffix kept. Only existing paths are rewritten; decorative
|
||||
@words / typos pass through unchanged."""
|
||||
def repl(m):
|
||||
lead, quoted, bare = m.group(1), m.group(3), m.group(4)
|
||||
raw = quoted if quoted is not None else bare
|
||||
trail = ''
|
||||
if quoted is None: # strip trailing prose punctuation
|
||||
stripped = raw.rstrip(',。,;;))]》>')
|
||||
trail, raw = raw[len(stripped):], stripped
|
||||
sm = _LINE_SUFFIX_RE.search(raw)
|
||||
suffix = sm.group(1) if sm else ''
|
||||
path = raw[:len(raw) - len(suffix)] if suffix else raw
|
||||
if not path:
|
||||
return m.group(0)
|
||||
exp = os.path.expanduser(path)
|
||||
absp = os.path.normpath(exp if os.path.isabs(exp) else os.path.join(root, exp))
|
||||
if not os.path.exists(absp): # decorative / typo → leave as-is
|
||||
return m.group(0)
|
||||
full = absp + suffix
|
||||
token = f'@"{full}"' if ' ' in full else f'@{full}'
|
||||
return lead + token + trail
|
||||
return _AT_ABS_RE.sub(repl, text)
|
||||
@@ -78,6 +78,8 @@
|
||||
.hint { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.cards {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
@@ -91,6 +93,9 @@
|
||||
background: rgba(31,42,68,.16);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.approvals-body::-webkit-scrollbar { width: 10px; }
|
||||
.approvals-body::-webkit-scrollbar-track { background: rgba(31,42,68,.08); border-radius: 10px; }
|
||||
.approvals-body::-webkit-scrollbar-thumb { background: rgba(31,42,68,.34); border-radius: 10px; border: 2px solid rgba(255,255,255,.65); }
|
||||
.empty { color: var(--muted); font-size: 13px; padding: 8px 4px; }
|
||||
|
||||
/* ---------- subagent 卡片(可折叠) ---------- */
|
||||
@@ -154,8 +159,8 @@
|
||||
.reply pre { white-space: pre-wrap; }
|
||||
|
||||
/* ---------- 任务奏折栏(无事则隐) ---------- */
|
||||
.approvals { display: none; }
|
||||
.approvals.show { display: block; }
|
||||
.approvals { display: none; min-height: 0; overflow: hidden; }
|
||||
.approvals.show { display: flex; flex: 0 0 420px; height: 420px; max-height: 42vh; flex-direction: column; }
|
||||
.approvals .head {
|
||||
background: linear-gradient(180deg, rgba(245,158,11,.14), rgba(245,158,11,.05));
|
||||
border-bottom: 1px solid rgba(245,158,11,.18);
|
||||
@@ -166,8 +171,9 @@
|
||||
background: var(--amber); border-radius: 99px; padding: 2px 9px;
|
||||
box-shadow: 0 4px 12px rgba(245,158,11,.35);
|
||||
}
|
||||
.approvals-body { padding: 13px; display: flex; flex-direction: column; gap: 11px; max-height: 38vh; overflow: auto; }
|
||||
.approvals-body { flex: 1; min-height: 0; padding: 13px; display: flex; flex-direction: column; gap: 11px; overflow-y: scroll; overflow-x: hidden; scrollbar-gutter: stable; }
|
||||
.approval {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 16px; background: rgba(255,255,255,.8);
|
||||
border: 1px solid rgba(245,158,11,.25);
|
||||
box-shadow: 0 8px 22px rgba(245,158,11,.10);
|
||||
|
||||
+514
-8
@@ -1,7 +1,7 @@
|
||||
"""`/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
|
||||
import ast, atexit, glob, json, os, random, re, shutil, threading, time
|
||||
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'temp', 'model_responses')
|
||||
_LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt')
|
||||
@@ -36,7 +36,7 @@ def _first_user(pairs):
|
||||
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()
|
||||
t = strip_project_mode(blk.get('text') or '').strip()
|
||||
if t and '<history>' not in t and not t.startswith('### [WORKING MEMORY]'):
|
||||
return t
|
||||
for p, _ in pairs[:1]:
|
||||
@@ -117,6 +117,7 @@ def _parse_native_history(pairs):
|
||||
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
|
||||
@@ -293,12 +294,19 @@ def _rounds_for_file(path, st):
|
||||
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."""
|
||||
def list_sessions(exclude_pid=None, exclude_log=None, rewind_root=None):
|
||||
"""Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only.
|
||||
|
||||
`exclude_log` (basename, e.g. 'model_responses_123456.txt') drops the caller's
|
||||
OWN current session — preferred over `exclude_pid`, which assumed the log file
|
||||
was named by PID (it isn't: agentmain mints a random 6-digit logid), so the
|
||||
pid tag never matched and the current session leaked into its own list."""
|
||||
files = glob.glob(_LOG_GLOB)
|
||||
if exclude_pid is not None:
|
||||
tag = f'model_responses_{exclude_pid}.txt'
|
||||
files = [f for f in files if not f.endswith(tag)]
|
||||
if exclude_log:
|
||||
files = [f for f in files if os.path.basename(f) != exclude_log]
|
||||
out = []
|
||||
valid_keys = []
|
||||
for f in files:
|
||||
@@ -316,6 +324,43 @@ def list_sessions(exclude_pid=None):
|
||||
valid_keys.append(key)
|
||||
out.append((f, mtime, preview, rounds))
|
||||
_save_rounds_cache(valid_keys)
|
||||
# 【门控·worldline】树感知发现:日志空/缺失但有非空世界线树的会话(回退到起点后日志被
|
||||
# 清空 → 上面 sz<32 跳过了)。仅当调用方显式传 rewind_root 时启用 → 其他 UI 不传,
|
||||
# 行为逐字节不变。只读 tree.json 的 nodes/head(不依赖 worldline 模块)。
|
||||
if rewind_root and os.path.isdir(rewind_root):
|
||||
have = {os.path.basename(p) for p, *_ in out}
|
||||
try:
|
||||
keys = os.listdir(rewind_root)
|
||||
except OSError:
|
||||
keys = []
|
||||
for key in keys:
|
||||
if not key.startswith('model_responses_'):
|
||||
continue
|
||||
log_name = key + '.txt'
|
||||
if log_name in have or log_name == exclude_log:
|
||||
continue
|
||||
log_path = os.path.join(_LOG_DIR, log_name)
|
||||
try: # 仅收"日志确实空/缺失"的(非空日志已被主循环收录)
|
||||
if os.path.getsize(log_path) >= 32:
|
||||
continue
|
||||
except OSError:
|
||||
pass # 缺失也算
|
||||
try:
|
||||
with open(os.path.join(rewind_root, key, 'tree.json'), encoding='utf-8') as fh:
|
||||
d = json.load(fh)
|
||||
except Exception:
|
||||
continue
|
||||
nodes = d.get('nodes') or {}
|
||||
real = [v for v in nodes.values() if v.get('kind') != 'origin']
|
||||
if not real: # 只有 origin 的空树 → 无内容,跳过
|
||||
continue
|
||||
try:
|
||||
mtime = os.path.getmtime(os.path.join(rewind_root, key, 'tree.json'))
|
||||
except OSError:
|
||||
mtime = 0
|
||||
head = d.get('head')
|
||||
title = (nodes.get(head, {}).get('title') if head else '') or '(已回退至会话起点)'
|
||||
out.append((log_path, mtime, f'[世界线] {title}', len(real)))
|
||||
out.sort(key=lambda x: x[1], reverse=True)
|
||||
return out
|
||||
_MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])')
|
||||
@@ -439,6 +484,17 @@ def handle(agent, query, display_queue):
|
||||
_INJECT_MARKERS = ('### [WORKING MEMORY]', '[SYSTEM TIPS]', '[SYSTEM]', '[System]',
|
||||
'[DANGER]', '### [总结提炼经验]')
|
||||
|
||||
# project_mode 插件把 `\n\n---\n[PROJECT MODE: <name>]\n…\n---` 追加在用户消息末尾
|
||||
# (见 plugins/project_mode._build_injection)。它会进日志,所以 /continue 重建 UI 时
|
||||
# 必须从显示文本里剔除,只留用户原话。不能加进 _INJECT_MARKERS——那会把整块(连用户
|
||||
# 原话)一起丢弃;这里只剜掉注入这一段后缀。
|
||||
_PM_BLOCK_RE = re.compile(r"\n*-{3,}\n\[PROJECT MODE:.*?\n-{3,}\s*$", re.DOTALL)
|
||||
|
||||
|
||||
def strip_project_mode(text: str) -> str:
|
||||
"""剔除用户文本尾部的 project-mode 注入块。"""
|
||||
return _PM_BLOCK_RE.sub("", text or "")
|
||||
|
||||
|
||||
def _user_text(prompt_body):
|
||||
"""User-typed text from a prompt JSON; '' if this is an agent auto-continuation.
|
||||
@@ -457,7 +513,7 @@ def _user_text(prompt_body):
|
||||
return ''
|
||||
for blk in blocks:
|
||||
if isinstance(blk, dict) and blk.get('type') == 'text':
|
||||
t = (blk.get('text') or '').strip()
|
||||
t = strip_project_mode(blk.get('text') or '').strip()
|
||||
if t and not any(mk in t for mk in _INJECT_MARKERS): return t
|
||||
return ''
|
||||
|
||||
@@ -475,11 +531,22 @@ def _assistant_text(response_body):
|
||||
|
||||
|
||||
def _format_tool_use(block):
|
||||
"""Match agent_loop.py:72 verbose tool-call header."""
|
||||
"""Match agent_loop.py:78 verbose tool-call header byte-for-byte.
|
||||
|
||||
MUST use agent_loop's `get_pretty_json`, not a plain `json.dumps`: the
|
||||
former rewrites a `script` arg's `"; "` into `";\\n "`, so for tools
|
||||
carrying `script` (code_run, web_execute_js) a plain dumps produces a
|
||||
*different* fence body. The TUI's write/read/code cards content-address
|
||||
their captures by `hash(get_pretty_json(args))`; a mismatched fence here
|
||||
means the hash misses and the card silently falls back to the raw block."""
|
||||
name = block.get('name', '?')
|
||||
args = block.get('input', {})
|
||||
try: pretty = json.dumps(args, indent=2, ensure_ascii=False).replace('\\n', '\n')
|
||||
except Exception: pretty = str(args)
|
||||
try:
|
||||
from agent_loop import get_pretty_json
|
||||
pretty = get_pretty_json(args)
|
||||
except Exception:
|
||||
try: pretty = json.dumps(args, indent=2, ensure_ascii=False).replace('\\n', '\n')
|
||||
except Exception: pretty = str(args)
|
||||
return f"🛠️ Tool: `{name}` 📥 args:\n````text\n{pretty}\n````\n"
|
||||
|
||||
|
||||
@@ -533,6 +600,165 @@ def _format_response_segment(response_body, tool_results):
|
||||
return '\n\n'.join(p for p in ['\n\n'.join(texts), '\n'.join(tool_parts)] if p)
|
||||
|
||||
|
||||
_PLAN_ENTRY_RE = re.compile(r'enter_plan_mode\(\s*[\'"]([^\'"]+plan\.md)[\'"]')
|
||||
|
||||
|
||||
def find_plan_entry(path):
|
||||
"""Last `enter_plan_mode("…plan.md")` call in a model_responses log.
|
||||
|
||||
Plan mode has exactly one entry point (plan_sop.md): a `code_run` tool call
|
||||
whose inline_eval script invokes `handler.enter_plan_mode(...)`. That call
|
||||
survives in the log as a structured `tool_use` block — unlike a plan path
|
||||
merely *mentioned* in chat text, it cannot be produced by the user typing
|
||||
a filename. Scanning these blocks is therefore the restore criterion for
|
||||
the plan card; the last match wins so re-entered plans track the newest.
|
||||
|
||||
Returns the plan.md path string as written in the script, or None.
|
||||
"""
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
return None
|
||||
last = None
|
||||
for _prompt, response in _pairs(content):
|
||||
try:
|
||||
blocks = ast.literal_eval(response)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(blocks, list):
|
||||
continue
|
||||
for b in blocks:
|
||||
if not (isinstance(b, dict) and b.get('type') == 'tool_use'
|
||||
and b.get('name') == 'code_run'):
|
||||
continue
|
||||
m = _PLAN_ENTRY_RE.search(str((b.get('input') or {}).get('script') or ''))
|
||||
if m:
|
||||
last = m.group(1)
|
||||
return last
|
||||
|
||||
|
||||
def iter_write_captures(path):
|
||||
"""Replay a log's file_write/file_patch/file_read calls into capture dicts
|
||||
the TUI can feed to its card renderers (`_WRITE_CAP`), keyed later by
|
||||
hash(get_pretty_json).
|
||||
|
||||
Live mode fills `_WRITE_CAP` from tool_before/tool_after hooks (with a real
|
||||
pre-write disk snapshot); on /continue that history is gone, but the
|
||||
structured `tool_use.input` survives in the log — clean, complete args. We
|
||||
also track each path's content *within this session* so a file
|
||||
written/patched several times shows real old→new diffs (not N× full "new
|
||||
file"). Files first touched by an untracked on-disk state still fall back
|
||||
to a full-content block.
|
||||
|
||||
Returns write entries `{"name", "args", "existed", "old", "status", "msg"}`
|
||||
and read entries `{"name", "args", "content"}` in call order. `status`/`msg`
|
||||
come from the matching tool_result so the header can show ✗ on a failed
|
||||
write; a read's `content` is the raw tool_result text (the read card strips
|
||||
its LLM-facing chrome itself).
|
||||
"""
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
return []
|
||||
pairs = _pairs(content)
|
||||
# tool_use_id -> (status, msg) from any prompt's tool_result blocks (the
|
||||
# result lands in the *next* round's Prompt as a tool_result whose content
|
||||
# is the json-dumped outcome.data, e.g. {"status":"success","msg":...}).
|
||||
# tr_raw keeps the undecoded text — a file_read result is plain text.
|
||||
tr_status, tr_raw = {}, {}
|
||||
for prompt, _ in pairs:
|
||||
try:
|
||||
msg_obj = json.loads(prompt)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(msg_obj, dict):
|
||||
continue
|
||||
for blk in msg_obj.get('content', []) or []:
|
||||
if not (isinstance(blk, dict) and blk.get('type') == 'tool_result'):
|
||||
continue
|
||||
tid = blk.get('tool_use_id')
|
||||
c = blk.get('content')
|
||||
if isinstance(c, list):
|
||||
c = ''.join(b.get('text', '') for b in c
|
||||
if isinstance(b, dict) and b.get('type') == 'text')
|
||||
if tid and isinstance(c, str):
|
||||
tr_raw[tid] = c
|
||||
try:
|
||||
d = json.loads(c) if isinstance(c, str) else None
|
||||
except Exception:
|
||||
d = None
|
||||
if tid and isinstance(d, dict):
|
||||
tr_status[tid] = (d.get('status'), str(d.get('msg') or ''))
|
||||
|
||||
out, state = [], {}
|
||||
for _prompt, response in pairs:
|
||||
try:
|
||||
blocks = ast.literal_eval(response)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(blocks, list):
|
||||
continue
|
||||
for b in blocks:
|
||||
if not (isinstance(b, dict) and b.get('type') == 'tool_use'):
|
||||
continue
|
||||
name = b.get('name')
|
||||
if name not in ('file_write', 'file_patch', 'file_read', 'code_run'):
|
||||
continue
|
||||
args = b.get('input') or {}
|
||||
p = args.get('path')
|
||||
if name == 'file_read':
|
||||
out.append({'name': name, 'args': args,
|
||||
'content': tr_raw.get(b.get('id'))})
|
||||
continue
|
||||
if name == 'code_run':
|
||||
# data = the tool_result text; a dict result is JSON, an
|
||||
# inline_eval / code-missing result is plain text. Pass the
|
||||
# parsed dict when possible so the card reads exit_code/stdout;
|
||||
# else the raw string (the card handles both).
|
||||
raw = tr_raw.get(b.get('id'))
|
||||
d = raw
|
||||
try:
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else None
|
||||
if isinstance(parsed, dict):
|
||||
d = parsed
|
||||
except Exception:
|
||||
pass
|
||||
out.append({'name': name, 'args': args, 'data': d})
|
||||
continue
|
||||
st, mg = tr_status.get(b.get('id'), (None, ''))
|
||||
if name == 'file_patch':
|
||||
# If this file's content is tracked within the session, pass it as
|
||||
# the pre-write full file so the renderer can do a whole-file diff
|
||||
# (real line numbers + context); else fall back to the fragment.
|
||||
pre = state.get(p, '')
|
||||
out.append({'name': name, 'args': args,
|
||||
'existed': p in state, 'old': pre,
|
||||
'status': st, 'msg': mg})
|
||||
if st == 'error':
|
||||
continue # failed call left the disk untouched — don't book it
|
||||
old = args.get('old_content') or ''
|
||||
if p in state and old:
|
||||
state[p] = state[p].replace(old, args.get('new_content') or '', 1)
|
||||
else: # file_write
|
||||
existed = p in state
|
||||
old = state.get(p, '')
|
||||
new = str(args.get('content') or '')
|
||||
mode = str(args.get('mode') or 'overwrite')
|
||||
out.append({'name': name, 'args': args, 'existed': existed, 'old': old,
|
||||
'status': st, 'msg': mg})
|
||||
if st == 'error':
|
||||
continue # failed call left the disk untouched — don't book it
|
||||
if mode == 'append':
|
||||
state[p] = old + new
|
||||
elif mode == 'prepend':
|
||||
state[p] = new + old
|
||||
else:
|
||||
state[p] = new
|
||||
return out
|
||||
|
||||
|
||||
def extract_ui_messages(path):
|
||||
"""Parse a model_responses log into [{role, content}, ...] for UI replay.
|
||||
|
||||
@@ -593,6 +819,286 @@ def handle_frontend_command(agent, query, exclude_pid=None):
|
||||
return msg
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 原地复原(in-place continue)共享层 —— 仅供 TUI(tui_v2/tui_v3 及其 rewind 副本)
|
||||
# 调用;其它前端(IM/qt/streamlit…)不调用这些函数,行为完全不受影响。
|
||||
#
|
||||
# 模型:每个会话 = 一个 `model_responses_<logid>.txt`,身份就是文件本身。
|
||||
# · 原地续 X = 把 agent 的 log_path 指回 X,之后的轮次追加到 X 本身(同一会话延续)。
|
||||
# · 拷贝续 X = 铸一个新 logid、把 X 拷进去,在副本上续;X 原件不动(并发安全)。
|
||||
# · 切走/新对话 = 释放当前锁、旧日志原样留作"空闲会话"(不存快照、不清空),新对话铸新 logid。
|
||||
#
|
||||
# 独占:每个 TUI 会话出生即持有自己日志的一把锁(`.locks/<logid>.lock`);
|
||||
# 整进程共用一个心跳线程,每 ~5s touch 锁文件 mtime(无 fsync)。
|
||||
# 判活 = 锁 mtime 在 30s 内新鲜;超 30s 视为持锁者已死,可被接管。
|
||||
# 抢锁用原子 O_EXCL;锁基础设施任何故障都降级为"假定空闲、放行续接",绝不阻断 /continue。
|
||||
# ===========================================================================
|
||||
|
||||
_LOCK_DIR = os.path.join(_LOG_DIR, '.locks')
|
||||
_HB_INTERVAL = 5.0 # 心跳间隔(秒)
|
||||
_STALE_AFTER = 30.0 # 超过这么久无心跳 → 持锁者视为已死,可接管
|
||||
_held_locks = set() # 本进程当前持有的 log_path 集合
|
||||
_hb_lock = threading.Lock()
|
||||
_hb_thread = None
|
||||
|
||||
|
||||
def _lock_path(log_path):
|
||||
base = os.path.splitext(os.path.basename(log_path))[0]
|
||||
return os.path.join(_LOCK_DIR, base + '.lock')
|
||||
|
||||
|
||||
def _read_lock(lock_file):
|
||||
try:
|
||||
with open(lock_file, encoding='utf-8') as fh:
|
||||
return json.load(fh)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _lock_fresh(lock_file):
|
||||
"""心跳新鲜度 = 锁文件 mtime 距今 < _STALE_AFTER。"""
|
||||
try:
|
||||
return (time.time() - os.path.getmtime(lock_file)) < _STALE_AFTER
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def session_occupant(log_path):
|
||||
"""若 `log_path` 正被一个活着的(心跳新鲜)进程持有,返回其 owner 元数据 dict;
|
||||
否则返回 None(空闲,或锁已过期可接管)。供 TUI 判断"原地 / 弹窗拷贝"。"""
|
||||
lf = _lock_path(log_path)
|
||||
meta = _read_lock(lf)
|
||||
if meta is not None and _lock_fresh(lf):
|
||||
return meta
|
||||
return None
|
||||
|
||||
|
||||
def acquire_lock(log_path, agent_id=None):
|
||||
"""尝试独占 `log_path`。成功(或锁设施故障降级)返回 True;
|
||||
仅当被另一活进程(心跳新鲜)持有时返回 False。"""
|
||||
try:
|
||||
os.makedirs(_LOCK_DIR, exist_ok=True)
|
||||
lf = _lock_path(log_path)
|
||||
meta = {'pid': os.getpid(), 'agent_id': agent_id,
|
||||
'log': os.path.basename(log_path),
|
||||
'started': time.time()}
|
||||
blob = json.dumps(meta, ensure_ascii=False)
|
||||
try:
|
||||
fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
|
||||
fh.write(blob)
|
||||
except FileExistsError:
|
||||
cur = _read_lock(lf)
|
||||
if cur and cur.get('pid') != os.getpid() and _lock_fresh(lf):
|
||||
return False # 被另一活进程持有
|
||||
# 过期锁 / 本进程自己的 → 接管(覆盖)。小竞态窗口可接受。
|
||||
try: os.remove(lf)
|
||||
except OSError: pass
|
||||
try:
|
||||
fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
|
||||
fh.write(blob)
|
||||
except FileExistsError:
|
||||
cur2 = _read_lock(lf)
|
||||
if cur2 and cur2.get('pid') != os.getpid() and _lock_fresh(lf):
|
||||
return False # 抢锁竞态输了
|
||||
with open(lf, 'w', encoding='utf-8') as fh:
|
||||
fh.write(blob)
|
||||
with _hb_lock:
|
||||
_held_locks.add(log_path)
|
||||
_ensure_hb_thread()
|
||||
return True
|
||||
except Exception:
|
||||
# 锁设施故障绝不阻断续接 —— 降级为"假定空闲、放行"。
|
||||
return True
|
||||
|
||||
|
||||
def release_lock(log_path):
|
||||
"""释放本进程对 `log_path` 的锁(只删自己持有/无主的锁文件)。"""
|
||||
with _hb_lock:
|
||||
_held_locks.discard(log_path)
|
||||
try:
|
||||
lf = _lock_path(log_path)
|
||||
cur = _read_lock(lf)
|
||||
if cur is None or cur.get('pid') == os.getpid():
|
||||
os.remove(lf)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _hb_tick():
|
||||
now = time.time()
|
||||
with _hb_lock:
|
||||
items = list(_held_locks)
|
||||
for lp in items:
|
||||
try:
|
||||
os.utime(_lock_path(lp), (now, now)) # 仅更新 mtime,无 fsync
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _hb_loop():
|
||||
while True:
|
||||
time.sleep(_HB_INTERVAL)
|
||||
try:
|
||||
_hb_tick()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_hb_thread():
|
||||
global _hb_thread
|
||||
with _hb_lock:
|
||||
if _hb_thread is None:
|
||||
_hb_thread = threading.Thread(target=_hb_loop,
|
||||
name='ga-session-heartbeat', daemon=True)
|
||||
_hb_thread.start()
|
||||
|
||||
|
||||
@atexit.register
|
||||
def _release_all_locks():
|
||||
for lp in list(_held_locks):
|
||||
release_lock(lp)
|
||||
|
||||
|
||||
def _new_log_path():
|
||||
"""铸一个新的 6 位 logid 日志路径(与 agentmain 同公式)。"""
|
||||
logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}'
|
||||
return os.path.join(_LOG_DIR, f'model_responses_{logid}.txt')
|
||||
|
||||
|
||||
def _retarget_log(agent, new_path):
|
||||
"""把 agent(及其所有 llmclient)的日志写入点切到 new_path —— 之后的轮次写这里。"""
|
||||
try:
|
||||
agent.log_path = new_path
|
||||
except Exception:
|
||||
pass
|
||||
for client in _agent_clients(agent):
|
||||
try: client.log_path = new_path
|
||||
except Exception: pass
|
||||
|
||||
|
||||
def is_snapshot(path):
|
||||
"""遗留快照存档(model_responses_snapshot_*.txt)。这类只能拷贝续,不参与原地
|
||||
(provisional,待 worktree 复审)。"""
|
||||
return os.path.basename(path).startswith('model_responses_snapshot_')
|
||||
|
||||
|
||||
def _clear_conversation_state(agent):
|
||||
"""清空对话状态(对齐 reset_conversation,但不碰日志文件)。"""
|
||||
if hasattr(agent, 'history'):
|
||||
agent.history = []
|
||||
for client in _agent_clients(agent):
|
||||
backend = getattr(client, 'backend', None)
|
||||
if backend is not None and hasattr(backend, 'history'):
|
||||
backend.history = []
|
||||
if hasattr(client, 'last_tools'):
|
||||
client.last_tools = ''
|
||||
if hasattr(agent, 'handler'):
|
||||
agent.handler = None
|
||||
|
||||
|
||||
def acquire_birth_lock(agent, agent_id=None):
|
||||
"""会话出生时持有自己当前日志的锁(新 logid 必然抢到)。TUI 在建会话时调用,
|
||||
使本会话对"占用检测"可见 —— 别的会话才能据此判定它是否还活着。"""
|
||||
lp = getattr(agent, 'log_path', '') or ''
|
||||
if lp:
|
||||
acquire_lock(lp, agent_id)
|
||||
|
||||
|
||||
def release_current(agent):
|
||||
"""切走:释放 agent 当前日志的锁,旧日志原样留作"空闲会话"(不存快照、不清空)。"""
|
||||
lp = getattr(agent, 'log_path', '') or ''
|
||||
if lp:
|
||||
release_lock(lp)
|
||||
|
||||
|
||||
def begin_fresh_session(agent, agent_id=None):
|
||||
"""新对话 / clear:释放当前锁(旧日志留作空闲会话)→ 铸新 logid 重指 → 持新锁 →
|
||||
清空对话状态。**替代 TUI 里的 reset_conversation**(不再存快照/清空旧日志)。"""
|
||||
try: agent.abort()
|
||||
except Exception: pass
|
||||
release_current(agent)
|
||||
newp = _new_log_path()
|
||||
_retarget_log(agent, newp)
|
||||
acquire_lock(newp, agent_id)
|
||||
_clear_conversation_state(agent)
|
||||
|
||||
|
||||
def _load_history_into(agent, path):
|
||||
"""把 `path` 解析进 backend.history(native;否则降级摘要)。镜像 restore() 的解析,
|
||||
但不 abort/不快照(日志重指由调用方先做好)。返回 (msg, is_full)。"""
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='replace') as fh:
|
||||
content = fh.read()
|
||||
except Exception as e:
|
||||
return f'❌ 读取失败: {e}', False
|
||||
pairs = _pairs(content)
|
||||
if not pairs:
|
||||
return f'❌ {os.path.basename(path)} 为空或格式不符', False
|
||||
history = _parse_native_history(pairs)
|
||||
name = os.path.basename(path)
|
||||
if history is not None:
|
||||
_replace_backend_history(agent, history)
|
||||
return f'✅ 已恢复 {len(pairs)} 轮完整对话({name})', True
|
||||
from chatapp_common import _restore_native_history, _restore_text_pairs
|
||||
summary = _restore_text_pairs(content) or _restore_native_history(content)
|
||||
if not summary:
|
||||
return f'❌ {name} 无法解析(非 native 且无摘要可提取)', False
|
||||
if hasattr(agent, 'history'):
|
||||
agent.history.extend(summary)
|
||||
n = sum(1 for l in summary if l.startswith('[USER]: '))
|
||||
return f'⚠️ 非 native 格式,降级恢复 {n} 轮摘要({name})', False
|
||||
|
||||
|
||||
def _is_empty_log(path):
|
||||
"""日志空(<32 字节)或缺失。用于 allow_empty:回退到会话起点后日志被清空的会话。"""
|
||||
try:
|
||||
return os.path.getsize(path) < 32
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def continue_inplace(agent, path, agent_id=None, allow_empty=False):
|
||||
"""原地续:把 agent 的日志指回 `path` 本身,之后轮次追加到 X,延续同一会话。
|
||||
调用方应已确认空闲(session_occupant 为 None);抢锁失败(被占)返回错误。
|
||||
`allow_empty`(仅 worldline UI 传):日志为空时不报错,按【空会话】恢复(清空对话,
|
||||
由调用方按 `.ga_rewind` 树重连),用于"回退至会话起点"的会话。返回 (msg, ok)。"""
|
||||
try: agent.abort()
|
||||
except Exception: pass
|
||||
if not acquire_lock(path, agent_id): # 先抢到目标锁;失败则保持现状,不丢自己的锁
|
||||
return '❌ 会话已被占用,无法原地接管', False
|
||||
cur = getattr(agent, 'log_path', '') or ''
|
||||
if cur and os.path.basename(cur) != os.path.basename(path):
|
||||
release_lock(cur) # 目标到手,旧会话释放为空闲(同一文件则不放)
|
||||
_retarget_log(agent, path)
|
||||
msg, ok = _load_history_into(agent, path)
|
||||
if not ok and allow_empty and _is_empty_log(path):
|
||||
_replace_backend_history(agent, []) # 空会话:清空对话(载入失败时它没被清)
|
||||
return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True
|
||||
return msg, ok
|
||||
|
||||
|
||||
def continue_copy(agent, path, agent_id=None, allow_empty=False):
|
||||
"""拷贝续:铸新 logid、把 `path` 内容拷进去,在副本上续;`path` 原件不动。
|
||||
用于"被占用→用户选拷贝"以及快照源。返回 (msg, ok)。"""
|
||||
try: agent.abort()
|
||||
except Exception: pass
|
||||
release_current(agent)
|
||||
newp = _new_log_path()
|
||||
try:
|
||||
shutil.copyfile(path, newp)
|
||||
except Exception:
|
||||
pass
|
||||
acquire_lock(newp, agent_id)
|
||||
_retarget_log(agent, newp)
|
||||
msg, ok = _load_history_into(agent, newp)
|
||||
if not ok and allow_empty and _is_empty_log(newp):
|
||||
_replace_backend_history(agent, [])
|
||||
return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True
|
||||
return msg, ok
|
||||
|
||||
|
||||
def install(cls):
|
||||
"""Wrap cls._handle_slash_cmd so /continue is handled before original dispatch."""
|
||||
orig = cls._handle_slash_cmd
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""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('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y)))
|
||||
self.label.bind('<B1-Motion>', self._drag)
|
||||
self.label.bind('<Double-1>', 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()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""/model 命令的 agent 无关逻辑: 拉模型列表 + 运行时改 model。
|
||||
被 tuiapp_v2 import; 不 patch 任何类, 不依赖 llmcore。"""
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def _is_mixin(b) -> bool:
|
||||
return type(b).__name__ == 'MixinSession'
|
||||
|
||||
|
||||
def _resolve(agent, sub: Optional[int] = None):
|
||||
"""返回 (真正持有 model 的 session, mixin或None)。"""
|
||||
b = agent.llmclient.backend
|
||||
if _is_mixin(b):
|
||||
return b._sessions[b._cur_idx if sub is None else sub], b
|
||||
return b, None
|
||||
|
||||
|
||||
def list_subsessions(agent) -> Optional[List[Tuple[int, str, bool]]]:
|
||||
"""mixin 渠道 → [(idx, name, is_current)]; 普通渠道 → None。"""
|
||||
b = agent.llmclient.backend
|
||||
if not _is_mixin(b):
|
||||
return None
|
||||
return [(i, s.name, i == b._cur_idx) for i, s in enumerate(b._sessions)]
|
||||
|
||||
|
||||
def current_model(agent, sub: Optional[int] = None) -> str:
|
||||
s, _ = _resolve(agent, sub)
|
||||
return s.model
|
||||
|
||||
|
||||
def fetch_models(agent, sub: Optional[int] = None, timeout: int = 10) -> List[str]:
|
||||
"""GET models 列表, 自动尝试 /models 与 /v1/models、原生头与 Bearer。"""
|
||||
s, _ = _resolve(agent, sub)
|
||||
base = s.api_base.rstrip('/')
|
||||
urls = [f'{base}/models'] + ([] if base.endswith('/v1') else [f'{base}/v1/models'])
|
||||
heads = [{'Authorization': f'Bearer {s.api_key}'}]
|
||||
if 'Claude' in type(s).__name__:
|
||||
heads.insert(0, {'x-api-key': s.api_key, 'anthropic-version': '2023-06-01'})
|
||||
err = None
|
||||
for url in urls:
|
||||
for h in heads:
|
||||
try:
|
||||
r = requests.get(url, headers=h, timeout=timeout,
|
||||
proxies=getattr(s, 'proxies', None),
|
||||
verify=getattr(s, 'verify', True))
|
||||
r.raise_for_status()
|
||||
data = r.json().get('data', []) # 非 JSON(如 HTML 首页)会抛错进入下一候选
|
||||
ids = {m['id'] for m in data if isinstance(m, dict) and m.get('id')}
|
||||
if ids:
|
||||
return sorted(ids)
|
||||
except Exception as e:
|
||||
err = e
|
||||
raise err or RuntimeError('no models endpoint')
|
||||
|
||||
|
||||
def set_model(agent, model: str, sub: Optional[int] = None) -> str:
|
||||
"""运行时改 model(内存态, mykey 重载/重启后还原)。返回结果描述。"""
|
||||
s, mixin = _resolve(agent, sub)
|
||||
old = s.model
|
||||
s.model = model # mixin 的 model 是只读 property, 必须落子 session
|
||||
warn = ""
|
||||
try: # 对齐 agentmain.next_llm 的中文 schema 切换
|
||||
from agentmain import load_tool_schema
|
||||
load_tool_schema('_cn' if any(x in model.lower() for x in ('glm', 'minimax', 'kimi')) else '')
|
||||
except Exception as ex: # schema 选错会实际影响 agent 行为, 半径不为零, 不静默
|
||||
warn = f" (⚠ schema 切换失败: {type(ex).__name__})"
|
||||
where = f"[{s.name}]" if mixin else s.name
|
||||
return f"{where}: {old} → {model}{warn}"
|
||||
|
||||
|
||||
# GA 配置层允许的全部档位(llmcore BaseSession._enum)。各协议的真实支持面不同:
|
||||
# Claude(output_config.effort) 只认 low/medium/high + xhigh→max, none/minimal
|
||||
# 会被 _apply_claude_thinking 打 WARN 忽略; OpenAI 系(reasoning_effort /
|
||||
# reasoning.effort) 原样透传, 由渠道端校验。
|
||||
EFFORT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
|
||||
|
||||
|
||||
def _protocols(agent) -> set:
|
||||
"""当前渠道涉及的协议集合 {'claude', 'openai'}。mixin 看全部子渠道(广播
|
||||
会落到每一个)。NativeOAISession 名字不含 'Claude', 正确归入 openai。"""
|
||||
b = agent.llmclient.backend
|
||||
sessions = b._sessions if _is_mixin(b) else [b]
|
||||
return {('claude' if 'Claude' in type(s).__name__ else 'openai')
|
||||
for s in sessions}
|
||||
|
||||
|
||||
def effort_note(level, protocols) -> str:
|
||||
"""单点描述某档位在给定协议上的特殊行为(空串=无特殊)。set_effort 的结果
|
||||
描述与 /effort picker 的行内备注共用它, 领域知识只在此编码一次。"""
|
||||
if level and 'claude' in protocols:
|
||||
if level in ('none', 'minimal'):
|
||||
return 'Claude 渠道忽略'
|
||||
if level == 'xhigh':
|
||||
return 'Claude 对应 max'
|
||||
return ''
|
||||
|
||||
|
||||
def current_effort(agent) -> str:
|
||||
return getattr(agent.llmclient.backend, 'reasoning_effort', None) or ''
|
||||
|
||||
|
||||
def set_effort(agent, value) -> str:
|
||||
"""运行时改 reasoning_effort(内存态)。空值/off 清除(不再发送 effort 字段)。
|
||||
直接设在 backend 上: MixinSession 把它列为 _BROADCAST_ATTRS, 会同步到所有
|
||||
子渠道(故障切换后档位不丢); 普通渠道就是 session 本身。请求时各协议现读
|
||||
该属性, 立即生效。"""
|
||||
e = (value or '').strip().lower()
|
||||
if e in ('', 'off', 'clear', 'unset'):
|
||||
e = None
|
||||
elif e not in EFFORT_LEVELS:
|
||||
return (f"无效 effort: {value!r}"
|
||||
f" (可选 {'/'.join(EFFORT_LEVELS)}, 留空或 off 清除)")
|
||||
b = agent.llmclient.backend
|
||||
old = getattr(b, 'reasoning_effort', None)
|
||||
b.reasoning_effort = e
|
||||
note = effort_note(e, _protocols(agent))
|
||||
tail = f" ({note})" if note else ""
|
||||
return f"reasoning_effort: {old or '(未设置)'} → {e or '(清除)'}{tail}"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB |
+14
-4
@@ -159,16 +159,26 @@ def current_step(messages, start_idx: int = 0, max_len: int = 60) -> str:
|
||||
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)."""
|
||||
def is_active(agent, messages=None, start_idx: int = 0,
|
||||
restored_path: str = "") -> bool:
|
||||
"""Plan mode is on. Primary: `working['in_plan_mode']`. Then
|
||||
`restored_path` — a path recovered from the transcript's structured
|
||||
`enter_plan_mode` tool_use by /continue (see continue_cmd.find_plan_entry);
|
||||
unlike the message scan it cannot be spoofed by a path typed in chat.
|
||||
Legacy fallback: a `plan_*/plan.md` referenced in this session's messages
|
||||
(no global scan) — only consulted when `messages` is passed."""
|
||||
if _stashed_plan_path(agent): return True
|
||||
if restored_path and _resolve_stashed(restored_path): return True
|
||||
return find_path_in_messages(messages, start_idx) is not None
|
||||
|
||||
|
||||
def resolve_path(agent, messages=None, start_idx: int = 0) -> Optional[str]:
|
||||
def resolve_path(agent, messages=None, start_idx: int = 0,
|
||||
restored_path: str = "") -> Optional[str]:
|
||||
p = _resolve_stashed(_stashed_plan_path(agent))
|
||||
if p: return p
|
||||
if restored_path:
|
||||
p = _resolve_stashed(restored_path)
|
||||
if p: return p
|
||||
return find_path_in_messages(messages, start_idx)
|
||||
|
||||
|
||||
|
||||
+39
-13
@@ -33,6 +33,7 @@ from typing import Optional
|
||||
|
||||
_USER_SHELL: tuple[list[str], str] | None = None
|
||||
|
||||
COMMIT_SIGNATURE_PROMPT = 'When you create a git commit, append "Co-Authored-By: GenericAgent <bot@gaagent.ai>" as the final line of the commit message.'
|
||||
|
||||
def detect_user_shell() -> tuple[list[str], str]:
|
||||
"""Return `([executable, ...flags_for_-c], display_name)` for the user's
|
||||
@@ -152,16 +153,32 @@ def build_update_prompt(args_text: str = "") -> str:
|
||||
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"
|
||||
"1. Run `git fetch upstream`; identify the current branch and whether there are "
|
||||
"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"
|
||||
"3. Align the COMMIT HISTORY to upstream (upstream wins):\n"
|
||||
" - If local commits exist: `git merge upstream/main` into the current branch, "
|
||||
"resolving any conflicts in favor of upstream; keep the local commits (a merge commit is fine).\n"
|
||||
" - Otherwise: `git reset --mixed upstream/main` so the history of local `main` "
|
||||
"matches upstream/main exactly. Do NOT create any new commit.\n"
|
||||
"4. IMPORTANT: also reconcile the WORKING TREE; step 3 alone is not enough. "
|
||||
"`git reset --mixed` and `git merge` only move history and the index. They do not "
|
||||
"rewrite files that already hold uncommitted local edits, so those stale files keep "
|
||||
"shadowing upstream's latest. That is why 'HEAD is aligned but the files are still "
|
||||
"old'. Reconcile per file:\n"
|
||||
" a. `git diff --name-only upstream/main` lists every tracked file whose working-tree "
|
||||
"content differs from upstream.\n"
|
||||
" b. Decide upstream-first per file. A stale leftover, or a local edit that reverts "
|
||||
"an upstream feature to an older form, takes upstream: back it up, then "
|
||||
"`git checkout upstream/main -- <file>`. A genuine local enhancement upstream lacks "
|
||||
"(local config, key-template placeholders, fork-only features) stays, re-applied on "
|
||||
"top of upstream's latest instead of overwriting it.\n"
|
||||
" c. Upstream wins; keep local only where it adds value upstream lacks. Never "
|
||||
"`git add -A`, never blanket whole-branch checkout, never blindly preserve everything.\n"
|
||||
"5. Do not create any git commit. Back up any file before overwriting it.\n"
|
||||
"6. Finish with a short summary: branch HEAD, distance vs upstream, per-file outcome "
|
||||
"(took upstream / kept+adapted local), and where backups went.\n"
|
||||
"\n"
|
||||
"#### Say 'Congratulations! 🎉' to the user\n"
|
||||
"After a successful update, say: \"Congratulations! 🎉 You have successfully "
|
||||
@@ -175,12 +192,21 @@ def build_update_prompt(args_text: str = "") -> str:
|
||||
return (
|
||||
"请你更新当前 GenericAgent 仓库,官方上游为 "
|
||||
"https://github.com/Lsdefine/GenericAgent 。\n"
|
||||
"1. 先 fetch upstream;识别当前分支,以及是否存在领先 upstream 的本地 commit。\n"
|
||||
"1. 先 `git 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"
|
||||
"3. 先对齐【提交历史】到上游(优先上游):\n"
|
||||
" - 若存在本地 commit:`git merge upstream/main` 合并进当前分支,冲突一律优先上游,保留本地 commit(可产生 merge commit)。\n"
|
||||
" - 否则:执行 `git reset --mixed upstream/main`,使本地 `main` 的提交历史与 upstream/main 完全对齐。不要创建任何新 commit。\n"
|
||||
"4. 重要:还须核对【工作区文件】,止步于第 3 步并不够。`git reset --mixed` 与 `git merge` "
|
||||
"只移动提交历史与索引,不会重写那些已带未提交本地改动的文件,于是这些过时文件继续覆盖在上游"
|
||||
"最新版之上。这正是“HEAD 已对齐,但文件其实还是旧的”的根因。逐文件核对:\n"
|
||||
" a. `git diff --name-only upstream/main` 列出工作区内容与上游不一致的全部 tracked 文件。\n"
|
||||
" b. 每个文件按优先上游判断。过时残留、或把上游特性改回旧版的本地改动,取上游:先备份,再 "
|
||||
"`git checkout upstream/main -- <file>`。上游没有且仍有效的本地增强(本机配置、密钥模板占位、"
|
||||
"fork 专属功能)保留,并在上游最新版上重新适配,而非整文件覆盖上游。\n"
|
||||
" c. 优先上游;本地仅保留上游缺失且有价值的部分。禁止 `git add -A`、禁止整分支 checkout 覆盖、禁止盲目全保留。\n"
|
||||
"5. 不要进行任何 git commit。覆盖任何文件前先备份。\n"
|
||||
"6. 最后给出简短小结:分支 HEAD、与上游差距、逐文件处理结果(取上游 / 保留并适配本地)、备份位置。\n"
|
||||
"\n"
|
||||
"#### 向用户说 'Congratulations! 🎉'\n"
|
||||
"更新成功后,请对用户说:\"Congratulations! 🎉 你已成功更新 GenericAgent!\"\n"
|
||||
|
||||
+82
-46
@@ -13,26 +13,49 @@ sys.path.append(os.path.abspath(script_dir))
|
||||
|
||||
import streamlit as st
|
||||
import time, json, re, threading, queue
|
||||
from datetime import timedelta
|
||||
from agentmain import GeneraticAgent
|
||||
import chatapp_common # activate /continue command (monkey patches GeneraticAgent)
|
||||
from continue_cmd import handle_frontend_command, reset_conversation, list_sessions, extract_ui_messages
|
||||
from btw_cmd import handle_frontend_command as btw_handle_frontend
|
||||
from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard
|
||||
|
||||
st.set_page_config(page_title="Cowork", layout="wide")
|
||||
st.set_page_config(page_title="Cowork", layout="wide", initial_sidebar_state="collapsed")
|
||||
|
||||
st.markdown("""
|
||||
<style>
|
||||
[data-testid="stBottom"]{position:fixed!important;bottom:0!important;left:0!important;right:0!important;width:100vw!important;z-index:999;background:var(--background-color,#fff)}
|
||||
@media (min-width:768px){[data-testid="stSidebar"][aria-expanded="true"]~div [data-testid="stBottom"]{left:300px!important;width:calc(100vw - 300px)!important}}
|
||||
.stMainBlockContainer{padding-bottom:10rem!important}
|
||||
</style>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
LANG = os.environ.get('GA_LANG', 'zh')
|
||||
if LANG not in ('zh', 'en'): LANG = 'zh'
|
||||
I18N = {
|
||||
'zh': {
|
||||
'force_stop': '强行停止任务',
|
||||
'reinject_tools': '重新注入工具',
|
||||
'desktop_pet': '🐱 桌面宠物',
|
||||
'suggest_btn': '🎯 给我找点事做',
|
||||
'suggest_prompt': '按照自主行动的规划部分,充分分析我的情况,给我生成一批TODO,务必让我感兴趣',
|
||||
'auto_start': '开始空闲自主行动',
|
||||
'auto_pause': '⏸️ 禁止自主行动',
|
||||
'auto_enable': '▶️ 允许自主行动',
|
||||
'auto_on_cap': '🟢 自主行动运行中,会在你离开它30分钟后自动进行',
|
||||
'auto_off_cap': '🔴 自主行动已停止',
|
||||
'auto_prompt': '[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。',
|
||||
},
|
||||
'en': {
|
||||
'force_stop': 'Force Stop',
|
||||
'reinject_tools': 'Reinject Tools',
|
||||
'desktop_pet': '🐱 Desktop Pet',
|
||||
'suggest_btn': '🎯 Suggest tasks',
|
||||
'suggest_prompt': 'Following the planning section of autonomous sop, analyze my situation thoroughly and generate a batch of TODOs that will interest me.',
|
||||
'auto_start': 'Start idle auto-action',
|
||||
'auto_pause': '⏸️ Pause auto-action',
|
||||
'auto_enable': '▶️ Enable auto-action',
|
||||
'auto_on_cap': '🟢 Auto-action enabled, triggers after 30min idle',
|
||||
'auto_off_cap': '🔴 Auto-action disabled',
|
||||
'auto_prompt': '[AUTO]🤖 User has been idle for over 30 minutes. As an autonomous agent, read the automation SOP and execute automatic tasks.',
|
||||
},
|
||||
}
|
||||
def T(key): return I18N.get(LANG, I18N['zh']).get(key, key)
|
||||
@@ -64,18 +87,12 @@ def render_sidebar():
|
||||
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')
|
||||
if not os.path.exists(pet_script):
|
||||
st.error("desktop_pet_v2.pyw not found")
|
||||
return
|
||||
subprocess.Popen([sys.executable, pet_script], **kwargs)
|
||||
def _pet_req(q):
|
||||
def _do():
|
||||
@@ -93,25 +110,44 @@ def render_sidebar():
|
||||
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
|
||||
if st.button(T('suggest_btn')):
|
||||
st.session_state['_inject_prompt'] = T('suggest_prompt')
|
||||
st.rerun(scope="app")
|
||||
st.divider()
|
||||
st.markdown("""<style>
|
||||
[data-testid="stSidebar"] .stTextArea textarea {
|
||||
field-sizing: content; min-height: 1.6em !important; height: auto !important;
|
||||
}
|
||||
</style>""", unsafe_allow_html=True)
|
||||
def _sync_loop_prompt():
|
||||
st.session_state.loop_prompt = st.session_state.loop_prompt_input
|
||||
loop_prompt = st.text_area("Loop prompt", value=st.session_state.get('loop_prompt', "继续" if LANG=='zh' else 'next'), key="loop_prompt_input", height=1, on_change=_sync_loop_prompt)
|
||||
if st.session_state.get('loop_enabled'):
|
||||
if st.button("⏹️ Stop Loop"):
|
||||
st.session_state.loop_enabled = False
|
||||
st.toast("⏹️ Loop stopped"); st.rerun(scope="app")
|
||||
st.caption("🔁 Looping")
|
||||
else:
|
||||
if st.button("🔁 Loop!"):
|
||||
st.session_state.loop_enabled = True
|
||||
st.session_state.loop_prompt = loop_prompt
|
||||
st.session_state['_inject_prompt'] = loop_prompt
|
||||
st.toast("🔁 Looping"); st.rerun(scope="app")
|
||||
st.divider()
|
||||
if st.button(T('auto_start')):
|
||||
st.session_state.last_reply_time = int(time.time()) - 1800
|
||||
st.session_state.autonomous_enabled = True
|
||||
st.rerun(scope="app")
|
||||
if st.session_state.autonomous_enabled:
|
||||
if st.button(T('auto_pause')):
|
||||
st.session_state.autonomous_enabled = False
|
||||
st.toast(T('auto_pause')); st.rerun(scope="app")
|
||||
st.caption(T('auto_on_cap'))
|
||||
else:
|
||||
if st.button(T('auto_enable'), type="primary"):
|
||||
st.session_state.autonomous_enabled = True
|
||||
st.toast("已将上次回复时间设为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("🔴 自主行动已停止")
|
||||
st.toast("✅"); st.rerun(scope="app")
|
||||
st.caption(T('auto_off_cap'))
|
||||
with st.sidebar: render_sidebar()
|
||||
|
||||
def fold_turns(text):
|
||||
@@ -216,6 +252,10 @@ def render_main_stream(prompt=None):
|
||||
if response:
|
||||
st.session_state.messages.append({"role": "assistant", "content": response})
|
||||
st.session_state.last_reply_time = int(time.time())
|
||||
# ── 循环回调:回答完成后自动注入下一条 ──
|
||||
if st.session_state.get('loop_enabled'):
|
||||
st.session_state['_inject_prompt'] = st.session_state.get('loop_prompt', '继续')
|
||||
st.rerun()
|
||||
|
||||
if "messages" not in st.session_state: st.session_state.messages = []
|
||||
for msg in st.session_state.messages:
|
||||
@@ -233,20 +273,6 @@ try:
|
||||
_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;"
|
||||
@@ -338,5 +364,15 @@ 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"""<div id="last-reply-time" style="display:none">{st.session_state.get('last_reply_time', int(time.time()))}</div>""", unsafe_allow_html=True)
|
||||
# ── 空闲自主行动:fragment 定时检测,替代 launch.pyw 的 idle_monitor ──
|
||||
@st.fragment(run_every=timedelta(minutes=5))
|
||||
def _idle_checker():
|
||||
if not st.session_state.get('autonomous_enabled'): return
|
||||
if st.session_state.get('display_queue') is not None: return # 正在运行中
|
||||
if st.session_state.get('loop_enabled'): return # 循环模式自己管
|
||||
last = st.session_state.get('last_reply_time', int(time.time()))
|
||||
if time.time() - last > 1800:
|
||||
st.session_state['_inject_prompt'] = T('auto_prompt')
|
||||
st.session_state['last_reply_time'] = int(time.time()) # 防重入
|
||||
st.rerun(scope="app")
|
||||
_idle_checker()
|
||||
+512
-51
@@ -32,6 +32,7 @@ from rich.markdown import Markdown
|
||||
from rich.text import Text
|
||||
from rich.theme import Theme
|
||||
from typing import Callable
|
||||
from frontends import at_complete, workspace_cmd # @ 补全 + /workspace(与 v2 共用)
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# i18n — minimal dict-based zh/en translation layer (inlined; was tui_v3_i18n.py)
|
||||
@@ -295,6 +296,10 @@ _I18N: dict[str, dict[str, str]] = {
|
||||
# menu / picker / palette
|
||||
'menu.default_title': 'Pick',
|
||||
'menu.hint': '↑↓ pick · Enter confirm · Esc cancel',
|
||||
'menu.hint.filter': 'type to filter · ↑↓ pick · Enter confirm · Esc cancel',
|
||||
'menu.search': 'filter workspaces, or type an abs path + Enter to create one',
|
||||
'menu.no_match': 'no match',
|
||||
'menu.free.hint': 'Enter to create/enter this path',
|
||||
'ask.default_q': 'answer:',
|
||||
'ask.title': '◉ answer',
|
||||
'ask.pending': ' +{n} pending',
|
||||
@@ -304,6 +309,19 @@ _I18N: dict[str, dict[str, str]] = {
|
||||
|
||||
# continue picker
|
||||
'continue.title': 'Restore historical session',
|
||||
'continue.occupied.title': 'Session in use (pid {p}) — copy it and continue?',
|
||||
'continue.occupied.copy': 'Copy & continue',
|
||||
'continue.occupied.cancel': 'Cancel',
|
||||
# /workspace (parity with v2; backed by workspace_cmd.py)
|
||||
'cmd.workspace.arg': '[path|off]',
|
||||
'cmd.workspace.desc': 'set working dir (abs path) and enter project mode',
|
||||
'ws.entered': '✅ entered workspace「{n}」',
|
||||
'ws.fail': '❌ workspace failed: {e}',
|
||||
'ws.exited': 'left workspace (project mode off; junction & files kept)',
|
||||
'ws.inactive': 'not in a workspace right now',
|
||||
'ws.none': 'no registered workspace yet; /workspace <abs path> to create/enter',
|
||||
'ws.pick.title': 'Pick a workspace (↑↓ choose · Enter confirm · Esc cancel)',
|
||||
'ws.restored': 'restored working dir: {t}',
|
||||
'continue.row.fmt': '{rel:>4} {rounds:>3}r {preview}',
|
||||
'continue.unit.round': 'r',
|
||||
|
||||
@@ -545,6 +563,10 @@ _I18N: dict[str, dict[str, str]] = {
|
||||
# menu / picker / palette
|
||||
'menu.default_title': '选择',
|
||||
'menu.hint': '↑↓ 选 · Enter 确认 · Esc 取消',
|
||||
'menu.hint.filter': '输入过滤 · ↑↓ 选 · Enter 确认 · Esc 取消',
|
||||
'menu.search': '输入筛选工作区或输入绝对路径回车新建工作区',
|
||||
'menu.no_match': '无匹配',
|
||||
'menu.free.hint': '回车以该路径新建/进入',
|
||||
'ask.default_q': '请回答:',
|
||||
'ask.title': '◉ 请回答',
|
||||
'ask.pending': ' +{n} 待答',
|
||||
@@ -554,6 +576,19 @@ _I18N: dict[str, dict[str, str]] = {
|
||||
|
||||
# continue picker
|
||||
'continue.title': '恢复历史会话',
|
||||
'continue.occupied.title': '该会话正被占用(pid {p})—— 从原会话拷贝一份继续?',
|
||||
'continue.occupied.copy': '拷贝一份继续',
|
||||
'continue.occupied.cancel': '取消',
|
||||
# /workspace(与 v2 一致;后端 workspace_cmd.py)
|
||||
'cmd.workspace.arg': '[path|off]',
|
||||
'cmd.workspace.desc': '设定工作目录(绝对路径)并进入项目模式',
|
||||
'ws.entered': '✅ 已进入 workspace「{n}」',
|
||||
'ws.fail': '❌ workspace 设定失败: {e}',
|
||||
'ws.exited': '已退出 workspace(项目模式关闭;junction 与文件保留)',
|
||||
'ws.inactive': '当前未处于 workspace 模式',
|
||||
'ws.none': '暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入',
|
||||
'ws.pick.title': '选择 workspace(↑↓ 选择,Enter 确认,Esc 取消)',
|
||||
'ws.restored': '已恢复工作目录: {t}',
|
||||
'continue.row.fmt': '{rel:>4} {rounds:>3}轮 {preview}',
|
||||
'continue.unit.round': '轮',
|
||||
|
||||
@@ -842,10 +877,12 @@ def _ptk_keypress_to_bytes(kp) -> bytes:
|
||||
return b'\x02'
|
||||
if has('right'):
|
||||
return b'\x06'
|
||||
# Home/End get dedicated bytes — 0x01 is already Ctrl+A (select-all) and
|
||||
# 0x05 has no editor handler, so reusing them would break/no-op the keys.
|
||||
if has('home'):
|
||||
return b'\x01'
|
||||
return b'\x07'
|
||||
if has('end'):
|
||||
return b'\x05'
|
||||
return b'\x14'
|
||||
if has('delete'):
|
||||
return b'\x7f'
|
||||
if has('backspace') or data == '\x7f' or data == '\x08':
|
||||
@@ -1336,6 +1373,12 @@ class AgentBridge:
|
||||
self.agent.llmclient = self.agent.llmclients[llm_no % len(self.agent.llmclients)]
|
||||
self.agent.inc_out = True
|
||||
self.agent.verbose = True
|
||||
from frontends.slash_cmds import COMMIT_SIGNATURE_PROMPT
|
||||
self.agent.extra_sys_prompts.append(COMMIT_SIGNATURE_PROMPT)
|
||||
# 默认普通模式:设 None 让 project_mode 插件不读 pid 文件锚(与 v2 一致)。
|
||||
# /workspace 绑定时改为项目名 + 真实路径。
|
||||
self.agent._ga_project_mode_name = None
|
||||
self.agent._ga_project_mode_workspace_path = ''
|
||||
# task_dir path enables ga's `_keyinfo` / `_intervene` consume paths.
|
||||
# PID-scoped so concurrent v3 processes don't share signal files.
|
||||
# Only the *path* is set here; the dir is created lazily by the writer
|
||||
@@ -1363,6 +1406,12 @@ class AgentBridge:
|
||||
if not getattr(self.agent, 'llmclient', None):
|
||||
self._healthy = False
|
||||
self._init_error = _t('err.no_llm')
|
||||
# 原地复原:本会话出生即持有自己日志的锁,使占用检测对它可见(别的会话据此判活)。
|
||||
try:
|
||||
from frontends import continue_cmd as _cc
|
||||
_cc.acquire_birth_lock(self.agent)
|
||||
except Exception:
|
||||
pass
|
||||
self._runner = threading.Thread(target=self._run_safe, daemon=True, name=f'ga-tui-agent')
|
||||
self._runner.start()
|
||||
|
||||
@@ -1451,6 +1500,15 @@ class AgentBridge:
|
||||
except Exception:
|
||||
return '?'
|
||||
|
||||
@property
|
||||
def llm_model(self) -> str:
|
||||
"""The concrete model id in use (e.g. claude-opus-4-8), not the
|
||||
channel group `llm_name` returns. Empty string when unavailable."""
|
||||
try:
|
||||
return self.agent.get_llm_name(model=True) or ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
def list_llms(self) -> list[tuple[int, str, bool]]:
|
||||
return self.agent.list_llms()
|
||||
|
||||
@@ -1587,7 +1645,9 @@ def _strip_bg(s: str) -> str:
|
||||
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.')
|
||||
# CSI (`\x1b[…`) or SS3 (`\x1bO…`, application-cursor mode for Home/End/arrows)
|
||||
# as whole sequences, else any 2-byte `\x1b.` — order matters so SS3 wins over `\x1b.`.
|
||||
_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1bO[@-~]|\x1b.')
|
||||
_FILE_REF_RE = re.compile(r'@([\w./\-~]+)')
|
||||
_PASTE_PH_RE = re.compile(r'\[Pasted text #(\d+) \+\d+ lines\]')
|
||||
_FILE_PH_RE = re.compile(r'\[File #(\d+)\]')
|
||||
@@ -1841,6 +1901,8 @@ def _cmds() -> list[tuple[str, str, str]]:
|
||||
('/scheduler', '', _t('cmd.scheduler.desc')),
|
||||
('/rewind', _t('cmd.rewind.arg'), _t('cmd.rewind.desc')),
|
||||
('/continue', _t('cmd.continue.arg'), _t('cmd.continue.desc')),
|
||||
('/workspace', _t('cmd.workspace.arg', default='[path|off]'),
|
||||
_t('cmd.workspace.desc', default='设定工作目录(绝对路径)并进入项目模式')),
|
||||
('/new', _t('cmd.new.arg'), _t('cmd.new.desc')),
|
||||
('/rename', _t('cmd.rename.arg'), _t('cmd.rename.desc')),
|
||||
('/clear', '', _t('cmd.clear.desc')),
|
||||
@@ -2060,6 +2122,13 @@ def _esc_repl(m: re.Match) -> bytes:
|
||||
return b'\x1d' # Shift+↓ → extend selection down
|
||||
if s in (b'\x1b[27;2;13~', b'\x1b[13;2u'):
|
||||
return b'\n' # Shift+Enter (modifyOtherKeys / kitty) → newline
|
||||
# Home/End arrive as raw VT sequences via PTK's KeyPress.data (special keys
|
||||
# carry their original escape bytes), so _ptk_keypress_to_bytes returns them
|
||||
# verbatim and they must be decoded here like the arrows above.
|
||||
if s in (b'\x1b[H', b'\x1b[1~', b'\x1b[7~', b'\x1bOH'):
|
||||
return b'\x07' # Home → internal jump-to-line-start
|
||||
if s in (b'\x1b[F', b'\x1b[4~', b'\x1b[8~', b'\x1bOF'):
|
||||
return b'\x14' # End → internal jump-to-line-end
|
||||
return b'' # swallow every other escape sequence
|
||||
|
||||
|
||||
@@ -2205,6 +2274,46 @@ def _clip_cells(s: str, width: int) -> str:
|
||||
return out
|
||||
|
||||
|
||||
def _cell_head(s: str, n: int) -> str:
|
||||
"""Keep the head within n cells, suffix … if truncated (省略末尾 — names)."""
|
||||
if cell_len(s) <= n:
|
||||
return s
|
||||
if n <= 1:
|
||||
return '…'
|
||||
out, w = '', 0
|
||||
for ch in s:
|
||||
c = cell_len(ch)
|
||||
if w + c > n - 1:
|
||||
break
|
||||
out += ch; w += c
|
||||
return out + '…'
|
||||
|
||||
|
||||
def _cell_mid(s: str, n: int) -> str:
|
||||
"""Keep head+tail within n cells, … in the middle (省略中间 — paths: project
|
||||
root and leaf both stay visible). CJK counts as 2 via cell_len."""
|
||||
if cell_len(s) <= n:
|
||||
return s
|
||||
if n <= 1:
|
||||
return '…'
|
||||
avail = n - 1
|
||||
head_budget = avail - avail // 2
|
||||
tail_budget = avail // 2
|
||||
head, w = '', 0
|
||||
for ch in s:
|
||||
c = cell_len(ch)
|
||||
if w + c > head_budget:
|
||||
break
|
||||
head += ch; w += c
|
||||
tail_rev, w = '', 0
|
||||
for ch in reversed(s):
|
||||
c = cell_len(ch)
|
||||
if w + c > tail_budget:
|
||||
break
|
||||
tail_rev += ch; w += c
|
||||
return head + '…' + tail_rev[::-1]
|
||||
|
||||
|
||||
def _term_safe_text(s: str) -> str:
|
||||
"""Normalize control chars whose terminal geometry is stateful.
|
||||
|
||||
@@ -2380,11 +2489,25 @@ class SB:
|
||||
# on_submit(_menu_sel) and ignores _menu_checked.
|
||||
self._menu_multi: bool = False
|
||||
self._menu_checked: set[int] = set()
|
||||
# opt-in 可过滤菜单(/workspace 用):输入过滤 + free_input 提交原文。
|
||||
self._menu_filterable: bool = False
|
||||
self._menu_free: bool = False
|
||||
self._menu_on_free = None
|
||||
self._menu_query: str = ''
|
||||
self._menu_all_labels: list[str] = [] # 未过滤的全部显示行
|
||||
self._menu_filter_keys: list[str] = [] # 与 all_labels 等长的可搜索文本(完整,不省略)
|
||||
self._menu_map: list[int] = [] # 可见行 idx → all_labels 原始 idx
|
||||
# Interactive command palette: index into _cmd_matches output when
|
||||
# buf starts with `/`. ↑↓ steer the highlight, Tab completes the
|
||||
# highlighted command, Enter still executes whatever is in buf.
|
||||
self._palette_sel: int = 0
|
||||
self._palette_scroll: int = 0 # viewport offset into the matches list
|
||||
# workspace 绑定(单会话 → 进程级一个)。空 = 普通模式。
|
||||
self._ws_name: str = ''
|
||||
self._ws_path: str = ''
|
||||
self._ws_link: str = ''
|
||||
# @ 候选缓存:(buf, pos) → list[path],避免每次按键重算 fuzzy_rank。
|
||||
self._at_cache: tuple | None = None
|
||||
|
||||
# ── live region ──
|
||||
#
|
||||
@@ -2395,7 +2518,9 @@ class SB:
|
||||
# in callers are harmless legacy resets (they zero state PTK ignores).
|
||||
|
||||
def _status_line(self, w: int) -> str:
|
||||
name = self._bridge.llm_name if self._bridge else '?'
|
||||
# Show the concrete model id; fall back to the channel group only when
|
||||
# the model is unavailable (e.g. a mixin without a single .model).
|
||||
name = (self._bridge.llm_model or self._bridge.llm_name) if self._bridge else '?'
|
||||
if self._asking:
|
||||
state = _t('status.asking')
|
||||
elif self._running:
|
||||
@@ -2409,7 +2534,12 @@ class SB:
|
||||
else:
|
||||
state = _t('status.ready')
|
||||
cost = _cost_str(self._bridge.agent) if self._bridge else ''
|
||||
return f'[main] {name} │ {state}{cost}'
|
||||
ws = ''
|
||||
if self._ws_name:
|
||||
disp = (os.path.basename((self._ws_path or '').rstrip('/\\'))
|
||||
or re.sub(r'-[0-9a-f]{8}$', '', self._ws_name))
|
||||
ws = f' │ ⌂ {_cell_head(disp, 18)}'
|
||||
return f'[main] {name} │ {state}{cost}{ws}'
|
||||
|
||||
# v2-style plan card budget: 5 rows max — header(1) + optional step(1) +
|
||||
# tasks(rest) + optional overflow(1). Grace periods avoid flicker when the
|
||||
@@ -2658,7 +2788,9 @@ class SB:
|
||||
def _show_menu(self, title: str, options: list[str], on_submit,
|
||||
hint: str | None = None, on_cancel=None,
|
||||
multi_select: bool = False,
|
||||
pre_checked: set[int] | None = None) -> None:
|
||||
pre_checked: set[int] | None = None,
|
||||
filterable: bool = False, free_input: bool = False,
|
||||
on_free=None, filter_keys: list[str] | None = None) -> None:
|
||||
"""Open a modal arrow-key menu in place of the input box.
|
||||
|
||||
The menu takes over the live region; ↑↓ move the highlight, Enter
|
||||
@@ -2676,11 +2808,23 @@ class SB:
|
||||
worked logically but left a one-frame window where PTK could render
|
||||
the menu with the wrong state — observed as /scheduler picker
|
||||
rendering all unchecked even though `reflect/scheduler.py` was alive.
|
||||
Passing the set up-front makes the open atomic."""
|
||||
Passing the set up-front makes the open atomic.
|
||||
|
||||
`filterable=True` adds a search box: printable chars / backspace edit a
|
||||
live query that filters rows by `filter_keys` (defaults to the visible
|
||||
labels; pass the full untruncated text so an elided middle still
|
||||
matches). `free_input=True` + `on_free` lets Enter on a query with NO
|
||||
match commit the raw query (e.g. an abs path → new workspace)."""
|
||||
if not options:
|
||||
return
|
||||
if not (filterable and free_input):
|
||||
return # 空列表但允许 free_input → 仍打开(可输路径新建)
|
||||
self._menu_active = True
|
||||
self._menu_options = list(options)
|
||||
self._menu_filterable = bool(filterable)
|
||||
self._menu_free = bool(free_input)
|
||||
self._menu_on_free = on_free
|
||||
self._menu_query = ''
|
||||
self._menu_all_labels = list(options)
|
||||
self._menu_filter_keys = list(filter_keys) if filter_keys else list(options)
|
||||
self._menu_title = title
|
||||
# In multi-select mode show a Space-aware hint by default so the user
|
||||
# discovers the toggle key without reading the docstring.
|
||||
@@ -2688,16 +2832,38 @@ class SB:
|
||||
self._menu_hint = hint
|
||||
elif multi_select:
|
||||
self._menu_hint = _t('menu.hint.multi', default='Space toggle · ↑↓ move · Enter submit · Esc cancel')
|
||||
elif filterable:
|
||||
self._menu_hint = _t('menu.hint.filter', default='输入过滤 · ↑↓ 选择 · Enter 确认 · Esc 取消')
|
||||
else:
|
||||
self._menu_hint = _t('menu.hint')
|
||||
self._menu_sel = 0
|
||||
self._menu_scroll = 0
|
||||
self._menu_on_submit = on_submit
|
||||
self._menu_on_cancel = on_cancel
|
||||
self._menu_multi = bool(multi_select)
|
||||
self._menu_checked = set(pre_checked) if pre_checked else set()
|
||||
self._menu_apply_filter() # 设 _menu_options + _menu_map(初始 query 为空 → 全量)
|
||||
self._render_live()
|
||||
|
||||
def _menu_apply_filter(self) -> None:
|
||||
"""Recompute visible rows (`_menu_options`) + map-to-original
|
||||
(`_menu_map`) from the live query. No-op shape when not filterable."""
|
||||
q = self._menu_query.strip().lower()
|
||||
if not self._menu_filterable or not q:
|
||||
self._menu_options = list(self._menu_all_labels)
|
||||
self._menu_map = list(range(len(self._menu_all_labels)))
|
||||
else:
|
||||
terms = q.split()
|
||||
self._menu_options, self._menu_map = [], []
|
||||
for i, key in enumerate(self._menu_filter_keys):
|
||||
kl = key.lower()
|
||||
if all(t in kl for t in terms):
|
||||
self._menu_options.append(self._menu_all_labels[i])
|
||||
self._menu_map.append(i)
|
||||
# Focus model (filterable): -1 = the input row itself (a selectable
|
||||
# object in the ↑↓ ring, v2-continue style); 0..n-1 = a candidate.
|
||||
# Typing keeps focus on the input row. Non-filterable menus start at 0.
|
||||
self._menu_sel = -1 if self._menu_filterable else 0
|
||||
self._menu_scroll = 0
|
||||
|
||||
def _close_menu(self) -> None:
|
||||
self._menu_active = False
|
||||
self._menu_options = []
|
||||
@@ -2709,6 +2875,13 @@ class SB:
|
||||
self._menu_on_cancel = None
|
||||
self._menu_multi = False
|
||||
self._menu_checked = set()
|
||||
self._menu_filterable = False
|
||||
self._menu_free = False
|
||||
self._menu_on_free = None
|
||||
self._menu_query = ''
|
||||
self._menu_all_labels = []
|
||||
self._menu_filter_keys = []
|
||||
self._menu_map = []
|
||||
|
||||
@staticmethod
|
||||
def _scroll_window(sel: int, total: int, visible: int, scroll: int) -> int:
|
||||
@@ -2729,6 +2902,36 @@ class SB:
|
||||
checked = sorted(self._menu_checked) if multi else None
|
||||
if not self._menu_active:
|
||||
return
|
||||
# Filterable: map the visible selection back to the original index; or,
|
||||
# when the query matches nothing, commit it verbatim via on_free
|
||||
# (free_input — e.g. a typed abs path → new workspace).
|
||||
if self._menu_filterable:
|
||||
mp = self._menu_map
|
||||
on_free = self._menu_on_free
|
||||
q = self._menu_query.strip()
|
||||
# 焦点在某个候选 → 选它(映射回原始 idx)。
|
||||
if sel >= 0 and self._menu_options:
|
||||
orig = mp[sel] if 0 <= sel < len(mp) else mp[0]
|
||||
self._close_menu()
|
||||
if cb is not None:
|
||||
try: cb(orig)
|
||||
except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
|
||||
return
|
||||
# 焦点在输入框(sel<0):有 query → free 提交(当作路径新建/进入)。
|
||||
if self._menu_free and q and on_free is not None:
|
||||
self._close_menu()
|
||||
try: on_free(q)
|
||||
except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
|
||||
return
|
||||
# 输入框空 + 有候选 → 选第一个(便捷)。
|
||||
if self._menu_options:
|
||||
orig = mp[0]
|
||||
self._close_menu()
|
||||
if cb is not None:
|
||||
try: cb(orig)
|
||||
except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
|
||||
return
|
||||
return # 无 query 无候选 → 维持菜单
|
||||
self._close_menu()
|
||||
if cb is not None:
|
||||
try:
|
||||
@@ -2767,14 +2970,50 @@ class SB:
|
||||
return ae.candidates[self._picker_sel]
|
||||
return None
|
||||
|
||||
def _at_root(self) -> str:
|
||||
# @ 索引根 = workspace(绑定时真实路径),否则 agent 实际工作目录
|
||||
# _ROOT/temp(file_read/code_run 都相对它),而非飘忽的 os.getcwd()。
|
||||
return self._ws_path or os.path.join(_ROOT, "temp")
|
||||
|
||||
def _at_active(self):
|
||||
"""@ 补全:返回 (query, at_pos_in_buf) 或 None。基于光标前、当前逻辑行的
|
||||
@token(@ 可在任意行任意位置;菜单 / ask 态不触发)。"""
|
||||
if self._asking is not None or self._menu_active:
|
||||
return None
|
||||
ls = self.buf.rfind('\n', 0, self.pos) + 1
|
||||
tok = at_complete.find_at_token(self.buf[ls:self.pos])
|
||||
if tok is None:
|
||||
return None
|
||||
query, at_in_line = tok
|
||||
return query, ls + at_in_line
|
||||
|
||||
def _at_candidates(self) -> list:
|
||||
"""当前 @token 的候选(带 (buf,pos) 缓存,避免每键重算 fuzzy)。"""
|
||||
act = self._at_active()
|
||||
if act is None:
|
||||
self._at_cache = None
|
||||
return []
|
||||
key = (self.buf, self.pos)
|
||||
if self._at_cache is not None and self._at_cache[0] == key:
|
||||
return self._at_cache[1]
|
||||
# 未绑 workspace → 索引根是 temp,相对路径不直观,候选用绝对路径(_hint_lines
|
||||
# 本就整条显示);绑了用相对(短)。
|
||||
items = at_complete.candidates_for(act[0], self._at_root(), absolute=not self._ws_path)
|
||||
self._at_cache = (key, items)
|
||||
return items
|
||||
|
||||
def _palette_total(self) -> int:
|
||||
# 当前 palette 候选数(↓ 键越界判断用):slash 命令 or @ 文件。
|
||||
if self._slash_visible():
|
||||
return len(self._cmd_matches(self.buf))
|
||||
return len(self._at_candidates())
|
||||
|
||||
def _cmd_matches(self, prefix: str) -> list[tuple[str, str, str]]:
|
||||
p = prefix.strip().lower()
|
||||
return [c for c in _cmds() if c[0].startswith(p)]
|
||||
|
||||
def _palette_visible(self) -> bool:
|
||||
"""True when the live `/`-command palette should appear and own ↑↓/Tab."""
|
||||
if self._asking is not None or self._menu_active:
|
||||
return False
|
||||
def _slash_visible(self) -> bool:
|
||||
"""True when the buffer is a live `/`-command prefix with matches."""
|
||||
if '\n' in self.buf or not self.buf.startswith('/'):
|
||||
return False
|
||||
ms = self._cmd_matches(self.buf)
|
||||
@@ -2784,19 +3023,29 @@ class SB:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _palette_visible(self) -> bool:
|
||||
"""True when the live palette should appear and own ↑↓/Tab — either the
|
||||
`/`-command palette (buf starts with /) or the `@` file palette (cursor
|
||||
sits in an @token). Same machinery, two candidate sources."""
|
||||
if self._asking is not None or self._menu_active:
|
||||
return False
|
||||
return self._slash_visible() or bool(self._at_candidates())
|
||||
|
||||
def _hint_lines(self, w: int) -> list[str]:
|
||||
"""Live `/`-command palette: scrollback-style list with an arrow-key
|
||||
highlight + scrolling viewport. ↑↓ move the highlight (handled in
|
||||
`_keys`), Tab completes the highlighted command, Enter completes into
|
||||
the input box. Long match lists scroll the visible window as sel walks
|
||||
past the edges (no wrap-around, no "N more" chrome)."""
|
||||
"""Live palette: scrollback-style list with an arrow-key highlight +
|
||||
scrolling viewport. ↑↓ move the highlight (handled in `_keys`), Tab /
|
||||
Enter complete the highlighted entry into the input box. Renders the
|
||||
`/`-command set when buf starts with `/`, else the `@` file candidates."""
|
||||
if not self._palette_visible():
|
||||
return []
|
||||
ms = self._cmd_matches(self.buf)
|
||||
total = len(ms)
|
||||
if self._slash_visible():
|
||||
rows = [f' {n:<11} {a:<8} {d}' if a else f' {n:<11} {d}'
|
||||
for n, a, d in self._cmd_matches(self.buf)]
|
||||
else:
|
||||
rows = [' @ ' + p for p in self._at_candidates()]
|
||||
total = len(rows)
|
||||
# Cap palette viewport to 6 rows so it doesn't squeeze the input box.
|
||||
visible = min(total, 6)
|
||||
# Clamp sel + slide scroll so sel is in-window.
|
||||
if self._palette_sel < 0 or self._palette_sel >= total:
|
||||
self._palette_sel = 0
|
||||
self._palette_scroll = self._scroll_window(self._palette_sel, total, visible, self._palette_scroll)
|
||||
@@ -2804,21 +3053,27 @@ class SB:
|
||||
end = min(total, start + visible)
|
||||
out: list[str] = []
|
||||
for i in range(start, end):
|
||||
n, a, d = ms[i]
|
||||
# `<11` + literal space guarantees a visible gap between the
|
||||
# 10-char names (/conductor, /morphling, /scheduler) and their
|
||||
# `[arg]` placeholder — without it they render as `/conductor[task]`.
|
||||
row = f' {n:<11} {a:<8} {d}' if a else f' {n:<11} {d}'
|
||||
if i == self._palette_sel:
|
||||
out.append(_ACCENT + _BOLD + _clip_cells(row, w) + _RST)
|
||||
else:
|
||||
out.append(_DIM + _clip_cells(row, w) + _RST)
|
||||
styled = (_ACCENT + _BOLD) if i == self._palette_sel else _DIM
|
||||
out.append(styled + _clip_cells(rows[i], w) + _RST)
|
||||
return out
|
||||
|
||||
def _tab(self) -> None:
|
||||
if self._asking is not None or self._menu_active:
|
||||
return
|
||||
if '\n' in self.buf or not self.buf.startswith('/'):
|
||||
# @ 文件补全:用选中候选替换光标处的 @token(format_pick 加引号/尾空格)。
|
||||
if not self._slash_visible():
|
||||
items = self._at_candidates()
|
||||
act = self._at_active()
|
||||
if not items or act is None:
|
||||
return
|
||||
idx = self._palette_sel if 0 <= self._palette_sel < len(items) else 0
|
||||
rep = at_complete.format_pick(items[idx])
|
||||
at_pos = act[1]
|
||||
self.buf = self.buf[:at_pos] + rep + self.buf[self.pos:]
|
||||
self.pos = at_pos + len(rep)
|
||||
self._palette_sel = 0
|
||||
self._palette_scroll = 0
|
||||
self._at_cache = None
|
||||
return
|
||||
ms = self._cmd_matches(self.buf)
|
||||
if not ms:
|
||||
@@ -3008,9 +3263,14 @@ class SB:
|
||||
# Use PTK's current viewport height (set on SB._h by the render loop).
|
||||
h = max(8, getattr(self, '_h', 24))
|
||||
visible = min(total, self._menu_visible_count(h))
|
||||
# Clamp sel and recompute scroll so sel is in-window.
|
||||
self._menu_sel = max(0, min(total - 1, self._menu_sel)) if total else 0
|
||||
self._menu_scroll = self._scroll_window(self._menu_sel, total, visible, self._menu_scroll)
|
||||
# Clamp sel and recompute scroll so sel is in-window. Filterable menus
|
||||
# allow sel == -1 (focus on the input row); clamp candidates to range
|
||||
# but leave -1 intact. _scroll_window treats -1 as "top".
|
||||
if self._menu_filterable:
|
||||
self._menu_sel = -1 if self._menu_sel < 0 else (min(total - 1, self._menu_sel) if total else -1)
|
||||
else:
|
||||
self._menu_sel = max(0, min(total - 1, self._menu_sel)) if total else 0
|
||||
self._menu_scroll = self._scroll_window(max(0, self._menu_sel), total, visible, self._menu_scroll)
|
||||
start = self._menu_scroll
|
||||
end = min(total, start + visible)
|
||||
|
||||
@@ -3051,6 +3311,22 @@ class SB:
|
||||
return r
|
||||
|
||||
rows = [top]
|
||||
# Filterable menu: a search line above the rows, showing the live query
|
||||
# with a caret. Empty match set renders a hint instead of blank.
|
||||
if self._menu_filterable:
|
||||
q = self._menu_query
|
||||
focused = (self._menu_sel < 0) # 焦点在输入框(可被 ↑↓ 选中的对象)
|
||||
# 整行单一 style(纯文本交给 row,颜色走 style 参)——内嵌 ANSI 会让
|
||||
# row() 的 pad=content_w-cell_len(ch) 把转义算进宽度,右边框就错位。
|
||||
# focused → 整行 accent(箭头+占位/输入都高亮,焦点明显);否则 dim。
|
||||
if q:
|
||||
text = '› ' + q + ('▏' if focused else '')
|
||||
else:
|
||||
text = '› ' + _t('menu.search')
|
||||
rows.extend(row(text, _ACCENT if focused else _DIM))
|
||||
if not self._menu_options:
|
||||
hint = (_t('menu.free.hint') if (self._menu_free and q.strip()) else _t('menu.no_match'))
|
||||
rows.extend(row(hint, _DIM))
|
||||
# Viewport scrolls as sel walks past the edges; the title's N/total tag
|
||||
# signals position, so no "N more" indicator rows.
|
||||
for i in range(start, end):
|
||||
@@ -3997,12 +4273,20 @@ class SB:
|
||||
self._run_shell(raw[1:].strip())
|
||||
return
|
||||
if raw.startswith('/'):
|
||||
# Expand pasted/file/image placeholders BEFORE dispatch so a command
|
||||
# carries the real pasted text, not a `[Pasted text #N]` marker — e.g.
|
||||
# `/morphling <pasted multi-line target>`. Without this the command
|
||||
# path returned here before the expansion below ever ran, so the agent
|
||||
# got the literal placeholder. `self.hist` already kept the raw input.
|
||||
expanded = self._expand(raw)
|
||||
# /btw owns its own live-region panel — keep the command itself
|
||||
# out of the main scrollback.
|
||||
cmd0 = (raw[1:].split(None, 1)[0] or '').lower()
|
||||
cmd0 = (expanded[1:].split(None, 1)[0] or '').lower()
|
||||
if cmd0 != 'btw':
|
||||
self._commit_user(raw)
|
||||
self._cmd(raw); return
|
||||
self._commit_user(expanded)
|
||||
self._cmd(expanded)
|
||||
self._pstore.clear(); self._fstore.clear(); self._imgs.clear()
|
||||
return
|
||||
# Expand placeholders FIRST so the agent receives the resolved text,
|
||||
# not the [Image #N] / [Pasted #N] markers. This matches the idle
|
||||
# submit path below — keeping the form identical means a queued
|
||||
@@ -4010,17 +4294,20 @@ class SB:
|
||||
imgs = [self._imgs[i] for i in
|
||||
(int(m.group(1)) for m in _IMG_PH_RE.finditer(raw)) if i in self._imgs]
|
||||
expanded = self._expand(raw)
|
||||
# @ mentions: agent 收绝对路径(file_read 相对自身 cwd,否则找不到),
|
||||
# scrollback 显示相对(简洁)。仅改路径根、不读内容。
|
||||
agent_text = at_complete.absolutize_mentions(expanded, self._at_root()) if "@" in expanded else expanded
|
||||
if self._running:
|
||||
wrapped = _t('pending.inject_wrap', text=expanded)
|
||||
wrapped = _t('pending.inject_wrap', text=agent_text)
|
||||
if self._bridge and self._bridge.inject_intervene(wrapped, track=True):
|
||||
self._pending.append(expanded)
|
||||
self._pending.append(agent_text)
|
||||
self._commit_user(_t('pending.queued_marker', text=expanded))
|
||||
self._pstore.clear(); self._fstore.clear(); self._imgs.clear()
|
||||
self._render_live()
|
||||
return
|
||||
# Agent went idle in the race — fall through to put_task.
|
||||
self._commit_user(expanded) # scrollback shows exactly what
|
||||
self._submit(expanded, imgs) # the agent receives, not the
|
||||
self._commit_user(expanded) # scrollback 显示相对
|
||||
self._submit(agent_text, imgs) # agent 收绝对
|
||||
self._pstore.clear(); self._fstore.clear(); self._imgs.clear() # drop placeholders
|
||||
|
||||
def _cost_section(self, tname: str, t, be) -> list[str]:
|
||||
@@ -4060,11 +4347,98 @@ class SB:
|
||||
"""Wipe the conversation: drop LLM history, clear the screen and every
|
||||
rendered block. Shared by /clear and /new."""
|
||||
from frontends import continue_cmd
|
||||
continue_cmd.reset_conversation(ag)
|
||||
continue_cmd.begin_fresh_session(ag) # 切走:旧日志留作空闲会话 + 铸新 logid(不存快照)
|
||||
_w('\x1b[2J\x1b[H'); self._painted = []; self._live_rows = 0
|
||||
self._blocks = []; self._streaming_block = None; self._sent = 0
|
||||
self._tool_base = 0; self._tools = {}
|
||||
|
||||
# ── workspace(与 v2 共用 workspace_cmd;单会话 → 进程级一个绑定)─────────
|
||||
def _bind_workspace(self, info, persist: bool = True) -> None:
|
||||
"""绑定 / 解绑 workspace。info=prepare() 的结果 dict → 绑定;None → 解绑。
|
||||
同步 agent 的 project_mode 属性(插件据此注入项目上下文),刷新 @ 索引根。
|
||||
|
||||
persist=False: 仅刷新内存绑定状态,不写 session→ws 映射表。续接时的
|
||||
reset(_bind_workspace(None)) 用它——原地续后 agent.log_path==被续文件,
|
||||
若持久化会把本会话映射抹成 ""(= 已 off),反而毁掉自动恢复。映射表只应由
|
||||
显式 /workspace、/workspace off 与成功的续接恢复来写。"""
|
||||
ag = self._bridge.agent if self._bridge else None
|
||||
if info:
|
||||
self._ws_name = info.get('name') or ''
|
||||
self._ws_path = info.get('target') or info.get('path') or ''
|
||||
self._ws_link = info.get('link') or ''
|
||||
pm_name, pm_path = (self._ws_name or None), self._ws_path
|
||||
else:
|
||||
self._ws_name = self._ws_path = self._ws_link = ''
|
||||
pm_name, pm_path = None, ''
|
||||
self._at_cache = None # 索引根可能变了,@ 候选缓存失效
|
||||
if ag is not None:
|
||||
try:
|
||||
ag._ga_project_mode_name = pm_name
|
||||
ag._ga_project_mode_workspace_path = pm_path
|
||||
except Exception:
|
||||
pass
|
||||
# 持久化绑定/off → /continue 即时恢复,不必先聊一轮留 PROJECT MODE 块。
|
||||
if persist:
|
||||
workspace_cmd.session_ws_set(getattr(ag, "log_path", "") or "", pm_path or "")
|
||||
at_complete.get_index(self._at_root()).warm() # 预热新根(或 CWD)
|
||||
|
||||
def _do_workspace_activate(self, path: str) -> str:
|
||||
r = workspace_cmd.prepare(path)
|
||||
if not r.get('ok'):
|
||||
return _t('ws.fail', default='❌ workspace 设定失败: {e}').format(e=r.get('error'))
|
||||
self._bind_workspace(r)
|
||||
# 显示名去 hash:真实目录 basename,退回剥 name 尾 hash。
|
||||
disp = (os.path.basename((r.get('target') or '').rstrip('/\\'))
|
||||
or re.sub(r'-[0-9a-f]{8}$', '', r.get('name') or ''))
|
||||
out = _t('ws.entered', default='✅ 已进入 workspace「{n}」').format(n=disp)
|
||||
if r.get('warning'):
|
||||
out += ' ⚠ ' + r['warning']
|
||||
return out
|
||||
|
||||
def _cmd_workspace(self, arg: str) -> None:
|
||||
arg = (arg or '').strip()
|
||||
if arg.lower() == 'off':
|
||||
if self._ws_name:
|
||||
self._bind_workspace(None)
|
||||
self.commit([_DIM + _t('ws.exited',
|
||||
default='已退出 workspace(项目模式关闭;junction 与文件保留)') + _RST])
|
||||
else:
|
||||
self.commit([_DIM + _t('ws.inactive', default='当前未处于 workspace 模式') + _RST])
|
||||
return
|
||||
if arg: # 直接路径:设定/进入
|
||||
self.commit([self._do_workspace_activate(arg)])
|
||||
return
|
||||
# 无参 → 菜单选已登记 workspace(去 hash / 名称末尾省略 / 路径中间省略)。
|
||||
items = workspace_cmd.registry_list()
|
||||
if not items:
|
||||
self.commit([_DIM + _t('ws.none',
|
||||
default='暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入') + _RST])
|
||||
return
|
||||
options: list[str] = []
|
||||
paths: list[str] = []
|
||||
fkeys: list[str] = []
|
||||
for it in items:
|
||||
disp = (os.path.basename((it['path'] or '').rstrip('/\\'))
|
||||
or re.sub(r'-[0-9a-f]{8}$', '', it['name']))
|
||||
age = _rel(it['last_used']) if it['last_used'] else '—'
|
||||
mem = (f"{it['mem_lines']}行" if it['mem_lines'] else '空')
|
||||
flag = ' ⚠' if it['dangling'] else ''
|
||||
options.append(f"{_cell_head(disp, 22)} · {_cell_mid(it['path'], 46)} · {age} · {mem}{flag}")
|
||||
paths.append(it['path'])
|
||||
# 搜索键含完整路径(显示中间省略,但搜索看完整 — 与 v2 _filter_choices 一致)。
|
||||
fkeys.append(f"{disp} {it['path']}")
|
||||
|
||||
def _pick(idx: int) -> None:
|
||||
self.commit([self._do_workspace_activate(paths[idx])])
|
||||
|
||||
def _free(q: str) -> None: # 输入框内回车一个绝对路径 → 新建/进入
|
||||
self.commit([self._do_workspace_activate(q)])
|
||||
|
||||
self._show_menu(_t('ws.pick.title',
|
||||
default='选择 workspace(输入过滤 / 绝对路径回车新建 · ↑↓ 选 · Esc 取消)'),
|
||||
options, _pick, filterable=True, free_input=True,
|
||||
on_free=_free, filter_keys=fkeys)
|
||||
|
||||
def _cmd(self, raw: str) -> None:
|
||||
assert self._bridge is not None
|
||||
parts = raw[1:].split(None, 1)
|
||||
@@ -4146,14 +4520,15 @@ class SB:
|
||||
# self.commit([_t('err.multi_session', name=name)])
|
||||
elif name == 'continue':
|
||||
from frontends import continue_cmd
|
||||
sess = continue_cmd.list_sessions(exclude_pid=os.getpid())
|
||||
sess = continue_cmd.list_sessions(exclude_log=os.path.basename(getattr(ag, "log_path", "") or ""))
|
||||
if not sess:
|
||||
self.commit([_DIM + _t('msg.no_history') + _RST]); return
|
||||
|
||||
def _do_restore(path: str) -> None:
|
||||
msg, _ = continue_cmd.restore(ag, path)
|
||||
def _rc_finish(path: str, msg: str) -> None:
|
||||
# restore 后:重放对话到 scrollback + 恢复 workspace。读 ag.log_path
|
||||
# (原地=源文件本身;拷贝=内容相同的新副本),内容一致。
|
||||
self.commit([_DIM + '┄┄ ' + _t('msg.continue_loading', name=os.path.basename(path)) + ' ┄┄' + _RST])
|
||||
for mm in continue_cmd.extract_ui_messages(path):
|
||||
for mm in continue_cmd.extract_ui_messages(getattr(ag, 'log_path', '') or path):
|
||||
c = (mm.get('content') or '').strip()
|
||||
if not c:
|
||||
continue
|
||||
@@ -4162,6 +4537,47 @@ class SB:
|
||||
else:
|
||||
self._commit_assistant(c)
|
||||
self.commit([_DIM + '┄┄ ' + _t('msg.continue_ready', msg=msg) + ' ┄┄' + _RST])
|
||||
# 自动恢复 workspace:续接的会话若在某个已登记 workspace 里工作过,
|
||||
# 重新绑定(必要时重建 junction),不触碰 project_mode 的进程锚。
|
||||
# persist=False:reset 绑定绝不写 session→ws 映射表——原地续把
|
||||
# agent.log_path 指回 path,若持久化会把本会话映射抹成 "",在读回
|
||||
# 之前就毁掉自动恢复。
|
||||
self._bind_workspace(None, persist=False)
|
||||
try:
|
||||
rec = workspace_cmd.session_ws_get(path) # 路径 / "" (off) / None(无记录)
|
||||
if rec is not None:
|
||||
ws_path = rec or None # "" → 该会话已 off,明确不恢复
|
||||
else:
|
||||
info = workspace_cmd.workspace_from_log(path) # 老会话:回退扫日志
|
||||
ws_path = info['path'] if info else None
|
||||
if ws_path:
|
||||
r = workspace_cmd.prepare(ws_path)
|
||||
if r.get('ok'):
|
||||
self._bind_workspace(r)
|
||||
self.commit([_DIM + '⌂ ' + _t('ws.restored',
|
||||
default='已恢复工作目录: {t}').format(t=r['target']) + _RST])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _do_restore(path: str) -> None:
|
||||
# 默认原地续。快照只能拷贝续;若被活进程占用 → 弹窗问是否拷贝一份。
|
||||
if continue_cmd.is_snapshot(path):
|
||||
msg, _ = continue_cmd.continue_copy(ag, path)
|
||||
_rc_finish(path, msg); return
|
||||
occ = continue_cmd.session_occupant(path)
|
||||
if occ is not None:
|
||||
def _pick(i):
|
||||
if i == 0:
|
||||
msg, _ = continue_cmd.continue_copy(ag, path)
|
||||
_rc_finish(path, msg)
|
||||
self._show_menu(
|
||||
_t('continue.occupied.title', p=occ.get('pid', '?')),
|
||||
[_t('continue.occupied.copy'),
|
||||
_t('continue.occupied.cancel')],
|
||||
_pick)
|
||||
return
|
||||
msg, _ = continue_cmd.continue_inplace(ag, path)
|
||||
_rc_finish(path, msg)
|
||||
|
||||
if arg:
|
||||
# Direct numeric argument still supported for power users / scripts.
|
||||
@@ -4199,6 +4615,8 @@ class SB:
|
||||
_do_restore(sess[idx][0])
|
||||
|
||||
self._show_menu(_t('continue.title'), options, _pick_session)
|
||||
elif name == 'workspace':
|
||||
self._cmd_workspace(arg)
|
||||
elif name == 'clear':
|
||||
self._reset_session(ag)
|
||||
self.commit([_DIM + _t('msg.cleared') + _RST])
|
||||
@@ -5121,11 +5539,28 @@ class SB:
|
||||
if self._menu_active:
|
||||
n = len(self._menu_options)
|
||||
if o == 0x10: # ↑
|
||||
if n:
|
||||
if self._menu_filterable:
|
||||
# ring [input(-1), 0..n-1]: ↑ from input → last cand;
|
||||
# ↑ from first cand → input; else step up.
|
||||
if self._menu_sel < 0:
|
||||
self._menu_sel = n - 1 if n else -1
|
||||
elif self._menu_sel == 0:
|
||||
self._menu_sel = -1
|
||||
else:
|
||||
self._menu_sel -= 1
|
||||
elif n:
|
||||
self._menu_sel = max(0, self._menu_sel - 1)
|
||||
self._render_live(); continue
|
||||
if o == 0x0e: # ↓
|
||||
if n:
|
||||
if self._menu_filterable:
|
||||
# ↓ from input → first cand; ↓ from last cand → input.
|
||||
if self._menu_sel < 0:
|
||||
self._menu_sel = 0 if n else -1
|
||||
elif self._menu_sel >= n - 1:
|
||||
self._menu_sel = -1
|
||||
else:
|
||||
self._menu_sel += 1
|
||||
elif n:
|
||||
self._menu_sel = min(n - 1, self._menu_sel + 1)
|
||||
self._render_live(); continue
|
||||
if self._menu_multi and ch == ' ': # Space toggles
|
||||
@@ -5148,6 +5583,19 @@ class SB:
|
||||
# a one-handed rollback without reaching for Esc.
|
||||
# Menus with no on_cancel just dismiss.
|
||||
self._menu_cancel(); continue
|
||||
# Filterable menu (e.g. /workspace): printable chars + Backspace
|
||||
# edit a live query that filters rows; everything else (arrows
|
||||
# 0x10/0x0e/0x02/0x06 are < 0x20) still falls through to swallow.
|
||||
if self._menu_filterable:
|
||||
if o in (0x08, 0x7f): # Backspace / Del
|
||||
if self._menu_query:
|
||||
self._menu_query = self._menu_query[:-1]
|
||||
self._menu_apply_filter()
|
||||
self._render_live(); continue
|
||||
if o >= 0x20 and o != 0x7f: # printable → append to query
|
||||
self._menu_query += ch
|
||||
self._menu_apply_filter()
|
||||
self._render_live(); continue
|
||||
# swallow everything else while the menu is up
|
||||
continue
|
||||
# ── ask_user picker key intercept ───────────────────────────────
|
||||
@@ -5220,7 +5668,7 @@ class SB:
|
||||
self._stash_draft()
|
||||
elif o == 0x0e: # ↓ visual-row down (history at bottom)
|
||||
if self._palette_visible():
|
||||
n = len(self._cmd_matches(self.buf))
|
||||
n = self._palette_total()
|
||||
self._palette_sel = min(n - 1, self._palette_sel + 1) if n else 0
|
||||
else:
|
||||
self._sel = None
|
||||
@@ -5253,6 +5701,14 @@ class SB:
|
||||
elif o == 0x01: # Ctrl+A — select all
|
||||
if self.buf:
|
||||
self._sel = 0; self.pos = len(self.buf)
|
||||
elif o == 0x07: # Home — jump to line start
|
||||
self._sel = None
|
||||
_, _, ls, _ = self._line_region()
|
||||
self.pos = ls
|
||||
elif o == 0x14: # End — jump to line end
|
||||
self._sel = None
|
||||
_, _, _, le = self._line_region()
|
||||
self.pos = le
|
||||
elif o == 0x18: # Ctrl+X — cut selection
|
||||
r = self._sel_range()
|
||||
if r:
|
||||
@@ -5581,6 +6037,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(_t('err.no_tty'))
|
||||
return 1
|
||||
_sweep_stale_task_dirs() # clear empty signal dirs left by prior runs
|
||||
try: workspace_cmd.cleanup() # remove dangling/unregistered workspace junctions
|
||||
except Exception: pass
|
||||
try: workspace_cmd.session_map_prune() # drop session→ws entries whose log is gone
|
||||
except Exception: pass
|
||||
at_complete.get_index(os.path.join(_ROOT, "temp")).warm() # @ 补全:预热未绑时的默认根(temp)
|
||||
SB().run()
|
||||
return 0
|
||||
|
||||
|
||||
+2786
-148
File diff suppressed because it is too large.
Load diff
+37
-12
@@ -63,20 +63,42 @@ class WxBotClient:
|
||||
return r.json()
|
||||
|
||||
def login_qr(self, poll_interval=2):
|
||||
r = requests.get(f'{API}/ilink/bot/get_bot_qrcode', params={'bot_type': 3}, headers={'User-Agent': UA}, timeout=10)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
# 获取二维码:对限流/缺字段(无 'qrcode')退避重试,避免崩溃→重启→更狠地打接口的死亡螺旋
|
||||
d = {}
|
||||
for attempt in range(6):
|
||||
try:
|
||||
r = requests.get(f'{API}/ilink/bot/get_bot_qrcode', params={'bot_type': 3}, headers={'User-Agent': UA}, timeout=10)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f'[QR登录] 获取二维码失败({e}),{2 ** attempt}s 后重试...'); time.sleep(2 ** attempt); continue
|
||||
if d.get('qrcode') and d.get('qrcode_img_content'): # 二维码 ID + 可扫图都就绪才算成功
|
||||
break
|
||||
print(f'[QR登录] 二维码未就绪(可能被限流,ret={d.get("ret")}),{2 ** attempt}s 后重试...')
|
||||
time.sleep(2 ** attempt)
|
||||
if not (d.get('qrcode') and d.get('qrcode_img_content')):
|
||||
raise RuntimeError('多次重试仍未获取到可扫二维码(疑似限流),请稍后重试')
|
||||
qr_id, url = d['qrcode'], d.get('qrcode_img_content', '')
|
||||
print(f'[QR登录] ID: {qr_id}')
|
||||
if url:
|
||||
img = self._tf.parent / 'wx_qr.png'
|
||||
qrcode.make(url).save(str(img)) # 保存到文件,不弹浏览器
|
||||
# 先打 ASCII 二维码(纯文本,无需 PIL;容器/无头环境靠它扫码)
|
||||
qr = qrcode.QRCode(border=1); qr.add_data(url); qr.make(fit=True); qr.print_ascii(invert=True)
|
||||
# 再尝试存 PNG 兜底——依赖 PIL,缺失/失败不应让登录崩溃
|
||||
try:
|
||||
qrcode.make(url).save(str(self._tf.parent / 'wx_qr.png'))
|
||||
except Exception as e:
|
||||
print(f'[QR登录] PNG 兜底保存失败({e}),用上方 ASCII 二维码扫码即可')
|
||||
last = ''
|
||||
while True:
|
||||
time.sleep(poll_interval)
|
||||
try: s = requests.get(f'{API}/ilink/bot/get_qrcode_status', params={'qrcode': qr_id}, headers={'User-Agent': UA}, timeout=60).json()
|
||||
except requests.exceptions.ReadTimeout: continue
|
||||
# 轮询状态:对所有网络异常 / 非 JSON(被限流时常返回 HTML)容错重试,
|
||||
# 不让单次抖动把进程打崩——配合 restart:unless-stopped 否则会死亡螺旋
|
||||
try:
|
||||
s = requests.get(f'{API}/ilink/bot/get_qrcode_status', params={'qrcode': qr_id}, headers={'User-Agent': UA}, timeout=60).json()
|
||||
except requests.exceptions.RequestException:
|
||||
continue
|
||||
except ValueError: # 响应非 JSON(限流/网关页)
|
||||
time.sleep(poll_interval); continue
|
||||
st = s.get('status', '')
|
||||
if st != last: print(f' 状态: {st}'); last = st
|
||||
if st == 'confirmed':
|
||||
@@ -415,11 +437,14 @@ if __name__ == '__main__':
|
||||
print(f'[NEW] Process starting {time.strftime("%m-%d %H:%M")}')
|
||||
bot = WxBotClient()
|
||||
if _do_relogin or not bot.token:
|
||||
if not sys.stdout.isatty():
|
||||
print('[Bot] no token and not interactive, exit.'); sys.exit(1)
|
||||
sys.stdout = sys.stderr = sys.__stdout__ # restore for QR display
|
||||
bot.login_qr()
|
||||
sys.stdout = sys.stderr = _logf
|
||||
# QR 登录在无 TTY 的容器里也可用:把二维码打到真实 stdout(docker logs
|
||||
# 可见),而不是日志文件——之前在重定向后才判 isatty(),文件句柄恒 false
|
||||
# 导致容器内必然退出,无法首次登录。PNG 仍存 ~/.wxbot/wx_qr.png 作兜底。
|
||||
sys.stdout = sys.stderr = sys.__stdout__ # restore for QR display (real stdout / container log)
|
||||
try:
|
||||
bot.login_qr()
|
||||
finally:
|
||||
sys.stdout = sys.stderr = _logf
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
print(f'WeChat Bot 已启动 (bot_id={bot.bot_id})', file=sys.__stdout__)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Workspace 命令的共享逻辑(tuiapp_v2 / tui_v3 复用)。
|
||||
|
||||
设计要点(详见对话设计稿):
|
||||
* **兼容旧入口** `plugins/project_mode.py` 与 `memory/project_mode_sop.md` 的 pid 锚。
|
||||
前端在
|
||||
`<repo>/temp/projects/<name>` 建一个指向用户真实绝对路径的目录联接(junction),
|
||||
并可按需写激活锚 `<repo>/temp/.active_project.<pid>`。project_mode 插件
|
||||
照常每轮注入 L1,并把 project_memory.md / 产物经 junction 写进真实仓库根
|
||||
(与 Claude Code 在仓库根放 CLAUDE.md 同理,已接受)。
|
||||
* **路径基准必须与插件一致**:插件的 `_TEMP` 是基于其 `__file__` 的 `<repo>/temp`
|
||||
绝对路径(非 cwd)。本模块也从自身 `__file__` 推 `<repo>/temp`(frontends/ 的上一级
|
||||
即 repo 根),两边独立计算但结果一致,互不 import。
|
||||
* **pid 语义**:插件读 `os.getpid()`(GA 进程)。前端就跑在 GA 进程里,写锚同样用
|
||||
`os.getpid()`(不是 SOP 里 code_run 子进程用的 getppid)。
|
||||
* **命名** `name = f"{basename}-{hash8}"`,hash8 = blake2b(规范化绝对路径)[:8]。
|
||||
同一 workspace 恒定同名(幂等复用);hash 后缀又让 junction 名不与其它 UI 人工命名的
|
||||
普通项目目录相撞。
|
||||
* **junction 安全**:检测用 reparse 属性(`os.path.islink` 对 junction 返回 False!);
|
||||
删除用 `os.rmdir`,**绝不 rmtree**(会击穿删真实文件)。cleanup 只动确认是 junction
|
||||
且悬空/未注册的条目,真实目录(其它 UI 的普通项目)一律不碰。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 路径基准(与 plugins/project_mode.py 的 _TEMP 保持一致)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _temp_root() -> str:
|
||||
return os.path.join(_REPO_ROOT, "temp")
|
||||
|
||||
|
||||
def _projects_root() -> str:
|
||||
return os.path.join(_temp_root(), "projects")
|
||||
|
||||
|
||||
def _anchor_path() -> str:
|
||||
"""激活锚,pid 键控,与插件 `_ANCHOR` 同。"""
|
||||
return os.path.join(_temp_root(), f".active_project.{os.getpid()}")
|
||||
|
||||
|
||||
def _registry_path() -> str:
|
||||
return os.path.join(_temp_root(), "workspaces.json")
|
||||
|
||||
|
||||
_REGISTRY_VERSION = 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 命名
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _norm_abspath(p: str) -> str:
|
||||
"""规范化绝对路径用于 hash:abspath + normcase(Windows 大小写不敏感 ->
|
||||
同一目录恒定同名)。不走 realpath,避免解析 junction/symlink 带来的意外。"""
|
||||
return os.path.normcase(os.path.abspath(p))
|
||||
|
||||
|
||||
def _ws_name(abs_path: str) -> str:
|
||||
base = os.path.basename(abs_path.rstrip("/\\")) or "ws"
|
||||
digest = hashlib.blake2b(_norm_abspath(abs_path).encode("utf-8")).hexdigest()[:8]
|
||||
return f"{base}-{digest}"
|
||||
|
||||
|
||||
def _link_path(name: str) -> str:
|
||||
return os.path.join(_projects_root(), name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# junction / symlink 跨平台封装(reparse 安全)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def make_dir_link(target_abs: str, link_path: str) -> bool:
|
||||
"""建目录联接。Windows 用 `mklink /J`(免管理员);POSIX 用 symlink。
|
||||
成功返回 True;失败打印到 stderr 并返回 False。"""
|
||||
target_abs = os.path.abspath(target_abs)
|
||||
parent = os.path.dirname(link_path)
|
||||
try:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"[workspace] mkdir {parent} failed: {e}\n")
|
||||
return False
|
||||
if os.name == "nt":
|
||||
# mklink 是 cmd 内建,必须经 cmd 调用。列表传参由 subprocess 负责加引号,
|
||||
# 兼容含空格/中文的路径。
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", link_path, target_abs],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"[workspace] mklink invoke failed: {e}\n")
|
||||
return False
|
||||
if r.returncode != 0 or not os.path.exists(link_path):
|
||||
sys.stderr.write(f"[workspace] mklink /J failed: "
|
||||
f"{(r.stderr or r.stdout or '').strip()}\n")
|
||||
return False
|
||||
return True
|
||||
# POSIX
|
||||
try:
|
||||
os.symlink(target_abs, link_path, target_is_directory=True)
|
||||
return True
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"[workspace] symlink failed: {e}\n")
|
||||
return False
|
||||
|
||||
|
||||
def is_dir_link(path: str) -> bool:
|
||||
"""是否目录联接/符号链接。**不能只用 os.path.islink**——它对 Windows junction
|
||||
返回 False。改看 reparse point 属性 + reparse tag。"""
|
||||
try:
|
||||
if os.path.islink(path): # POSIX symlink、Windows 符号链接
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
if os.name != "nt":
|
||||
return False
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except OSError:
|
||||
return False
|
||||
attrs = getattr(st, "st_file_attributes", 0)
|
||||
reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
||||
if not (attrs & reparse):
|
||||
return False
|
||||
# 进一步认 tag:挂载点(junction)或符号链接
|
||||
tag = getattr(st, "st_reparse_tag", 0)
|
||||
mount = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003)
|
||||
syml = getattr(stat, "IO_REPARSE_TAG_SYMLINK", 0xA000000C)
|
||||
if tag:
|
||||
return tag in (mount, syml)
|
||||
return True # 有 reparse 属性但拿不到 tag,保守视作链接(我们只在此目录建链)
|
||||
|
||||
|
||||
def link_target(path: str) -> Optional[str]:
|
||||
"""读链接目标;清洗 Windows 的 \\??\\ / \\\\?\\ 前缀。失败返回 None。"""
|
||||
try:
|
||||
t = os.readlink(path)
|
||||
except OSError:
|
||||
return None
|
||||
for pre in ("\\??\\", "\\\\?\\"):
|
||||
if t.startswith(pre):
|
||||
t = t[len(pre):]
|
||||
break
|
||||
return t
|
||||
|
||||
|
||||
def remove_dir_link(path: str) -> bool:
|
||||
"""只摘掉链接本身,绝不递归删目标。Windows junction / 符号链接目录用 os.rmdir,
|
||||
POSIX symlink 用 os.unlink。**调用前务必 is_dir_link 确认。**"""
|
||||
try:
|
||||
if os.name == "nt":
|
||||
os.rmdir(path)
|
||||
else:
|
||||
os.unlink(path)
|
||||
return True
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"[workspace] remove link {path} failed: {e}\n")
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 注册表 temp/workspaces.json(本功能私有;v2/v3 可能并发 -> 原子写)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def registry_load() -> dict:
|
||||
try:
|
||||
with open(_registry_path(), encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION:
|
||||
items = data.get("items")
|
||||
if isinstance(items, dict):
|
||||
return items
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _registry_save(items: dict) -> None:
|
||||
path = _registry_path()
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = f"{path}.{os.getpid()}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump({"version": _REGISTRY_VERSION, "items": items},
|
||||
fh, ensure_ascii=False, separators=(",", ":"))
|
||||
os.replace(tmp, path)
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"[workspace] registry save failed: {e}\n")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 会话→工作区映射 temp/session_workspaces.json — 让 /continue 即时恢复,不必先
|
||||
# 聊一轮在日志留 PROJECT MODE 块。key=会话日志名, value=workspace 真实路径,
|
||||
# ""=已 off(区别于缺 key=无记录→回退扫日志)。手动操作触发、极低频,照搬注册
|
||||
# 表原子写、不加锁。
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _session_map_path() -> str:
|
||||
return os.path.join(_temp_root(), "session_workspaces.json")
|
||||
|
||||
|
||||
def _session_map_load() -> dict:
|
||||
try:
|
||||
with open(_session_map_path(), encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION:
|
||||
items = data.get("items")
|
||||
if isinstance(items, dict):
|
||||
return items
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _session_map_save(items: dict) -> None:
|
||||
path = _session_map_path()
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = f"{path}.{os.getpid()}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump({"version": _REGISTRY_VERSION, "items": items},
|
||||
fh, ensure_ascii=False, separators=(",", ":"))
|
||||
os.replace(tmp, path)
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"[workspace] session map save failed: {e}\n")
|
||||
|
||||
|
||||
def session_ws_set(log_path: str, target: str) -> None:
|
||||
"""记录会话绑定的 workspace 路径;target="" 表示该会话已 off。"""
|
||||
key = os.path.basename(log_path or "")
|
||||
if not key:
|
||||
return
|
||||
items = _session_map_load()
|
||||
items[key] = target or ""
|
||||
_session_map_save(items)
|
||||
|
||||
|
||||
def session_ws_get(log_path: str):
|
||||
"""路径=绑定 / ""=已 off / None=无记录(调用方回退扫日志)。"""
|
||||
key = os.path.basename(log_path or "")
|
||||
return _session_map_load().get(key) if key else None
|
||||
|
||||
|
||||
def session_map_prune() -> None:
|
||||
"""删掉日志文件已不存在的孤儿条目(启动时调一次)。"""
|
||||
items = _session_map_load()
|
||||
logdir = os.path.join(_temp_root(), "model_responses")
|
||||
alive = {k: v for k, v in items.items() if os.path.isfile(os.path.join(logdir, k))}
|
||||
if len(alive) != len(items):
|
||||
_session_map_save(alive)
|
||||
|
||||
|
||||
def registry_upsert(name: str, abs_path: str) -> None:
|
||||
items = registry_load()
|
||||
items[name] = {"path": os.path.abspath(abs_path), "last_used": int(time.time())}
|
||||
_registry_save(items)
|
||||
|
||||
|
||||
def registry_remove(name: str) -> None:
|
||||
items = registry_load()
|
||||
if items.pop(name, None) is not None:
|
||||
_registry_save(items)
|
||||
|
||||
|
||||
def _mem_lines(link: str) -> int:
|
||||
"""project_memory.md 行数(经 junction 读真实文件);读不到返回 0。"""
|
||||
mp = os.path.join(link, "project_memory.md")
|
||||
try:
|
||||
with open(mp, encoding="utf-8", errors="replace") as fh:
|
||||
return sum(1 for _ in fh)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def registry_list() -> list[dict]:
|
||||
"""供 picker 候选列表:[{name, path, last_used, mem_lines, dangling}],按最近使用倒序。"""
|
||||
out = []
|
||||
for name, ent in registry_load().items():
|
||||
path = (ent or {}).get("path") or ""
|
||||
out.append({
|
||||
"name": name,
|
||||
"path": path,
|
||||
"last_used": int((ent or {}).get("last_used") or 0),
|
||||
"mem_lines": _mem_lines(_link_path(name)) if path else 0,
|
||||
"dangling": not (path and os.path.isdir(path)),
|
||||
})
|
||||
out.sort(key=lambda x: x["last_used"], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 校验
|
||||
# --------------------------------------------------------------------------- #
|
||||
def validate_path(abs_path: str) -> tuple[bool, str]:
|
||||
if not abs_path or not abs_path.strip():
|
||||
return False, "路径为空"
|
||||
p = abs_path.strip().strip('"').strip("'")
|
||||
if not os.path.isabs(p):
|
||||
return False, "需要绝对路径"
|
||||
if os.name == "nt" and p.startswith("\\\\"):
|
||||
return False, "不支持网络路径(UNC):junction 无法指向网络位置"
|
||||
if not os.path.exists(p):
|
||||
return False, f"路径不存在: {p}"
|
||||
if not os.path.isdir(p):
|
||||
return False, "不是目录"
|
||||
if _norm_abspath(p).startswith(_norm_abspath(_temp_root())):
|
||||
return False, "该路径已在 temp 内,无需 workspace"
|
||||
return True, ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 主流程
|
||||
# --------------------------------------------------------------------------- #
|
||||
def prepare(abs_path: str) -> dict:
|
||||
"""准备 workspace,但不写进程级激活锚。返回:
|
||||
{ok, name, link, target, mem_text, warning, error}。
|
||||
流程:校验 -> name -> 幂等建链 -> 确保 project_memory.md 存在 -> 注册 -> 回读记忆。
|
||||
TUI 多会话隔离使用本函数,避免多个 session 争用同一个 pid 锚。"""
|
||||
p = abs_path.strip().strip('"').strip("'") if abs_path else ""
|
||||
ok, msg = validate_path(p)
|
||||
if not ok:
|
||||
return {"ok": False, "error": msg}
|
||||
target = os.path.abspath(p)
|
||||
name = _ws_name(target)
|
||||
link = _link_path(name)
|
||||
warning = ""
|
||||
|
||||
# 幂等建链
|
||||
if os.path.lexists(link):
|
||||
if is_dir_link(link):
|
||||
cur = link_target(link)
|
||||
if cur and _norm_abspath(cur) == _norm_abspath(target):
|
||||
pass # 已指向同一目标 -> 复用
|
||||
else:
|
||||
remove_dir_link(link)
|
||||
if not make_dir_link(target, link):
|
||||
return {"ok": False, "error": "重建 junction 失败(见 stderr)"}
|
||||
else:
|
||||
# 极罕见:同名真实目录占位(其它 UI 的普通项目)。绝不覆盖。
|
||||
return {"ok": False,
|
||||
"error": f"{link} 已是真实目录(可能是其它项目),拒绝覆盖"}
|
||||
else:
|
||||
if not make_dir_link(target, link):
|
||||
return {"ok": False, "error": "创建 junction 失败(见 stderr)"}
|
||||
|
||||
# 确保 project_memory.md 存在(经 junction 落到真实仓库根)
|
||||
mem_path = os.path.join(link, "project_memory.md")
|
||||
if not os.path.isfile(mem_path):
|
||||
try:
|
||||
open(mem_path, "a", encoding="utf-8").close()
|
||||
except OSError as e:
|
||||
warning = f"无法创建 project_memory.md: {e}"
|
||||
|
||||
mem_text = ""
|
||||
try:
|
||||
with open(mem_path, encoding="utf-8", errors="replace") as fh:
|
||||
mem_text = fh.read()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
registry_upsert(name, target)
|
||||
|
||||
return {"ok": True, "name": name, "link": link, "target": target,
|
||||
"mem_text": mem_text, "warning": warning, "error": ""}
|
||||
|
||||
|
||||
def activate(abs_path: str) -> dict:
|
||||
"""设定并激活进程级 workspace。保留给旧 SOP / 非多会话 UI 使用。"""
|
||||
r = prepare(abs_path)
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
try:
|
||||
with open(_anchor_path(), "w", encoding="utf-8") as fh:
|
||||
fh.write(r["name"])
|
||||
except OSError as e:
|
||||
r = dict(r)
|
||||
r.update({"ok": False, "error": f"写激活锚失败: {e}"})
|
||||
return r
|
||||
|
||||
|
||||
def deactivate() -> bool:
|
||||
"""仅删激活锚;junction 与文件保留。返回是否原本处于激活态。"""
|
||||
anchor = _anchor_path()
|
||||
if os.path.isfile(anchor):
|
||||
try:
|
||||
os.remove(anchor)
|
||||
return True
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"[workspace] deactivate failed: {e}\n")
|
||||
return False
|
||||
|
||||
|
||||
def current() -> Optional[dict]:
|
||||
"""当前激活的 workspace:{name, path};未激活返回 None。"""
|
||||
anchor = _anchor_path()
|
||||
try:
|
||||
name = open(anchor, encoding="utf-8").read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
if not name:
|
||||
return None
|
||||
ent = registry_load().get(name) or {}
|
||||
return {"name": name, "path": ent.get("path") or ""}
|
||||
|
||||
|
||||
def is_dangling(name: str) -> bool:
|
||||
"""junction 指向的真实目标是否已失效(被删/盘断开)。"""
|
||||
link = _link_path(name)
|
||||
if not is_dir_link(link):
|
||||
return True
|
||||
t = link_target(link)
|
||||
return not (t and os.path.isdir(t))
|
||||
|
||||
|
||||
def remove(name: str) -> None:
|
||||
"""显式注销:删 junction(不动真实文件)+ 删注册表条目。若正激活该项目则一并删锚。"""
|
||||
link = _link_path(name)
|
||||
if is_dir_link(link):
|
||||
remove_dir_link(link)
|
||||
registry_remove(name)
|
||||
cur = current()
|
||||
if cur and cur["name"] == name:
|
||||
deactivate()
|
||||
|
||||
|
||||
# project_mode 插件注入的标记:`[PROJECT MODE: <name>]`(见 _build_injection)。
|
||||
# 它随用户消息写进 model_responses 日志,故可据此判断被 /continue 的会话当时
|
||||
# 在哪个 workspace。
|
||||
_PM_RE = re.compile(r"\[PROJECT MODE:\s*([^\]\n]+?)\s*\]")
|
||||
|
||||
|
||||
def workspace_from_log(log_path: str) -> Optional[dict]:
|
||||
"""扫一份 model_responses 日志,返回它最后激活的 workspace {name, path};
|
||||
仅当该 name 是**已注册的 workspace**(在 workspaces.json 里)才返回——
|
||||
普通 SOP 项目(无 hash、不在注册表)一律忽略。"""
|
||||
try:
|
||||
with open(log_path, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
return None
|
||||
names = _PM_RE.findall(content)
|
||||
if not names:
|
||||
return None
|
||||
name = names[-1].strip() # 最后一次激活胜出
|
||||
ent = registry_load().get(name)
|
||||
if not ent or not ent.get("path"):
|
||||
return None
|
||||
return {"name": name, "path": ent["path"]}
|
||||
|
||||
|
||||
def cleanup() -> None:
|
||||
"""v2/v3 启动时调一次:清理 temp/projects/ 下**悬空或未注册**的 junction。
|
||||
安全纪律:只处理 is_dir_link 确认的链接;真实目录(其它 UI 的普通项目)一律跳过。"""
|
||||
proot = _projects_root()
|
||||
if not os.path.isdir(proot):
|
||||
return
|
||||
registered = set(registry_load().keys())
|
||||
try:
|
||||
entries = os.listdir(proot)
|
||||
except OSError:
|
||||
return
|
||||
for nm in entries:
|
||||
link = os.path.join(proot, nm)
|
||||
if not is_dir_link(link):
|
||||
continue # 真实目录 -> 不碰
|
||||
t = link_target(link)
|
||||
dangling = not (t and os.path.isdir(t))
|
||||
if dangling or nm not in registered:
|
||||
remove_dir_link(link)
|
||||
@@ -0,0 +1,968 @@
|
||||
"""GA 世界线(Rewind / checkpoint)统一后端 —— **UI 无关,单文件,tui/gui 共用**。
|
||||
|
||||
三块合一(本文件不含任何 Textual/前端交互逻辑,只产数据与做存储):
|
||||
1. **持久化**(`RewindStore`):content-addressable blob + checkpoint 树 + 全持久化。
|
||||
- 统一 **checkpoint 树**;**content-addressable blob**(内容 sha256 去重);
|
||||
- 只追 `file_write`/`file_patch`(路径由调用方在 `tool_before` 钩子喂入);
|
||||
- **global baseline**(每文件「首次改前」)解决「回退到该文件被追踪之前」。
|
||||
2. **视图模型 + 压缩**(`CheckpointTree`/`tree_from_store`/`CompressedTree`):把真实
|
||||
store 投影成只读树并强制折叠线性段,供任何前端渲染。
|
||||
3. **导航数学 + 恢复编排**(`next_same_depth`/`nearest_depth_node`/`restore_plan`):
|
||||
纯计算;`restore_plan` 算出回退后的 history/文件/prefill 并落地(改文件+移 HEAD+
|
||||
重写投影),前端只管把结果刷到自己的界面。
|
||||
|
||||
参考 Claude Code file-history 思路,按 GA 改造。纯 Python、可离线测试;线程安全交调用方
|
||||
(agent 跑在子线程,约定仅 agent 空闲时 restore)。详见需求文档(ga_rewind_tui_requirements)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
import zlib
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from rich.cells import cell_len, set_cell_size
|
||||
|
||||
# 该值为 None 表示「该版本文件不存在」(对应 CC 的 backupFileName: null)。
|
||||
_ABSENT = None
|
||||
|
||||
|
||||
class RewindStore:
|
||||
"""一个 session 的 checkpoint 树 + blob 库。
|
||||
|
||||
用法(典型时序):
|
||||
store = RewindStore(root, cwd)
|
||||
# 改前(tool_before 钩子,file_write/file_patch 执行前):
|
||||
store.track_pre_edit(abs_path)
|
||||
# ...工具实际写盘...
|
||||
# 段末(用户提问边界 / turn 收尾):
|
||||
store.commit("修复 stdout", hist_len=42)
|
||||
# 浏览 / 恢复:
|
||||
store.apply_code(node_id) # 把工作区还原到该节点
|
||||
store.rewind_head(node_id) # 只移 HEAD(下次 commit 即从此分叉)
|
||||
"""
|
||||
|
||||
def __init__(self, root: str, cwd: str) -> None:
|
||||
self.root = os.path.abspath(root)
|
||||
self.cwd = os.path.abspath(cwd)
|
||||
self.objects_dir = os.path.join(self.root, "objects")
|
||||
self.tree_path = os.path.join(self.root, "tree.json")
|
||||
|
||||
# id -> {parent, children[], title, created, kind, hist_len, files{rel: hash|None}}
|
||||
self.nodes: dict[str, dict] = {}
|
||||
self.head: str | None = None
|
||||
self.root_id: str | None = None
|
||||
self._seq = 0
|
||||
# 全局:每个被追踪文件的「首次改前」状态(hash 或 None=当时不存在)。
|
||||
self.baseline: dict[str, str | None] = {}
|
||||
self.tracked: set[str] = set()
|
||||
|
||||
# 段内暂存:本段触碰过的相对路径(commit 时落定)。
|
||||
self._touched: set[str] = set()
|
||||
# 最近一次 restore 的 redo 点(内存级软保险②),结构同 node.files。
|
||||
self._redo: dict | None = None
|
||||
|
||||
self.load()
|
||||
if self.nodes:
|
||||
self._ensure_origin() # 迁移:旧树(无 origin)挂一个「会话起点」根
|
||||
|
||||
# --------------------------------------------------------------- origin
|
||||
def _ensure_origin(self) -> None:
|
||||
"""保证存在一个虚拟根「会话起点」(空状态):所有真实 checkpoint 都是它的
|
||||
后代。幂等。第一次 commit / 迁移旧树 / resume 时调用。
|
||||
|
||||
- 它代表「第一轮对话之前」,使「回退到第一个会话之前」= 普通地 rewind 到它;
|
||||
- files={} + hist_len=0 → apply_code(origin) 经 baseline 回退 = 还原到空起点;
|
||||
- 它是唯一真根 → 不会出现 forest / head=None。"""
|
||||
if any(nd.get("kind") == "origin" for nd in self.nodes.values()):
|
||||
if self.head is None:
|
||||
self.head = self.root_id
|
||||
return
|
||||
oid = "origin"
|
||||
while oid in self.nodes:
|
||||
oid += "_"
|
||||
tops = self.top_level_nodes()
|
||||
self.nodes[oid] = {"parent": None, "children": list(tops),
|
||||
"title": "会话起点", "created": time.time(),
|
||||
"kind": "origin", "hist_len": 0, "files": {}}
|
||||
for t in tops:
|
||||
self.nodes[t]["parent"] = oid
|
||||
self.root_id = oid
|
||||
if self.head is None:
|
||||
self.head = oid
|
||||
|
||||
# ------------------------------------------------------------------ paths
|
||||
def key(self, abs_path: str) -> str:
|
||||
"""绝对路径在 cwd 下 → 存相对(参考 CC maybeShortenFilePath);否则原样。"""
|
||||
ap = os.path.abspath(abs_path)
|
||||
try:
|
||||
if os.path.commonpath([ap, self.cwd]) == self.cwd:
|
||||
return os.path.relpath(ap, self.cwd).replace(os.sep, "/")
|
||||
except ValueError:
|
||||
pass # 跨盘符(Windows)等:commonpath 抛错 → 用绝对路径
|
||||
return ap.replace(os.sep, "/")
|
||||
|
||||
def _abs(self, rel: str) -> str:
|
||||
if os.path.isabs(rel) or (len(rel) > 1 and rel[1] == ":"):
|
||||
return os.path.normpath(rel)
|
||||
return os.path.normpath(os.path.join(self.cwd, rel))
|
||||
|
||||
# ------------------------------------------------------------------ blobs
|
||||
def _put_blob(self, data: bytes) -> str:
|
||||
h = hashlib.sha256(data).hexdigest()
|
||||
p = os.path.join(self.objects_dir, h[:2], h)
|
||||
if not os.path.exists(p):
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
tmp = p + ".tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(zlib.compress(data, 6))
|
||||
os.replace(tmp, p)
|
||||
return h
|
||||
|
||||
def _get_blob(self, h: str) -> bytes:
|
||||
with open(os.path.join(self.objects_dir, h[:2], h), "rb") as f:
|
||||
return zlib.decompress(f.read())
|
||||
|
||||
def _snapshot(self, abs_path: str) -> str | None:
|
||||
"""当前内容 → blob hash;文件不存在 → None。"""
|
||||
try:
|
||||
with open(abs_path, "rb") as f:
|
||||
data = f.read()
|
||||
except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError):
|
||||
return _ABSENT
|
||||
return self._put_blob(data)
|
||||
|
||||
# --------------------------------------------------------------- tracking
|
||||
def track_pre_edit(self, abs_path: str) -> None:
|
||||
"""改前埋点:首次见到该文件时记下它的「改前」状态作 baseline。
|
||||
|
||||
幂等——同一文件同段内多次调用只在首次抓 baseline(file_patch 连改不会
|
||||
覆盖原始版本,对应 CC trackEdit 的 v1 保护)。"""
|
||||
rel = self.key(abs_path)
|
||||
self._touched.add(rel)
|
||||
if rel not in self.baseline:
|
||||
self.baseline[rel] = self._snapshot(abs_path) # 改前内容,可能是 None
|
||||
self.tracked.add(rel)
|
||||
|
||||
def commit(self, title: str, hist_len: int | None = None, kind: str = "edit",
|
||||
history: list | None = None) -> str:
|
||||
"""段末落节点:继承 HEAD.files,把本段触碰文件的「改后」内容写进新节点。
|
||||
|
||||
`history` 给定时(对话树化):切 `history[parent.hist_len:当前]` 为本轮对话增量,
|
||||
存成 content-addressable conv blob,节点记其 hash + `hist_len=len(history)`。
|
||||
此刻索引可靠 → 增量精确,恢复时沿路径拼回即可,不再靠会漂的绝对下标。
|
||||
|
||||
无触碰文件时也会落节点(纯对话推进也是 checkpoint)。返回新节点 id。"""
|
||||
self._ensure_origin() # 第一次提交前先确保有「会话起点」根
|
||||
parent = self.head # 至少是 origin,永不为 None
|
||||
files = dict(self.nodes[parent]["files"]) if parent is not None else {}
|
||||
for rel in self._touched:
|
||||
files[rel] = self._snapshot(self._abs(rel)) # 改后内容,删除则 None
|
||||
|
||||
conv = None
|
||||
if history is not None:
|
||||
parent_len = self.nodes[parent].get("hist_len") if parent is not None else 0
|
||||
parent_len = parent_len or 0 # None(旧节点) / origin → 0
|
||||
delta = list(history[parent_len:])
|
||||
hist_len = len(history)
|
||||
conv = self._put_blob(
|
||||
json.dumps(delta, ensure_ascii=False, default=str).encode("utf-8"))
|
||||
|
||||
nid = self._new_id()
|
||||
self.nodes[nid] = {
|
||||
"parent": parent,
|
||||
"children": [],
|
||||
"title": title,
|
||||
"created": time.time(),
|
||||
"kind": kind,
|
||||
"hist_len": hist_len,
|
||||
"files": files,
|
||||
"conv": conv,
|
||||
}
|
||||
if parent is not None:
|
||||
self.nodes[parent]["children"].append(nid)
|
||||
elif self.root_id is None or self.root_id not in self.nodes:
|
||||
self.root_id = nid # 只认第一个根;rewind-到-根之前再提交会产生第二个顶层节点(forest)
|
||||
self.head = nid
|
||||
self._touched.clear()
|
||||
self.save()
|
||||
return nid
|
||||
|
||||
# ------------------------------------------------------------- navigation
|
||||
def rewind_head(self, node_id) -> None:
|
||||
"""移动 HEAD(不动文件)。从非叶 HEAD 下次 commit 即 append 子节点 = fork。
|
||||
node_id=None 表示「回到根之前」(空起点),下次 commit 产生新的顶层节点。"""
|
||||
if node_id is not None and node_id not in self.nodes:
|
||||
raise KeyError(node_id)
|
||||
self.head = node_id
|
||||
self.save()
|
||||
|
||||
def top_level_nodes(self) -> list[str]:
|
||||
"""所有顶层节点(parent 为 None 或父已不存在)。通常一个 root,rewind-到-根
|
||||
之前再提交会出现多个(forest)。"""
|
||||
return [nid for nid, nd in self.nodes.items()
|
||||
if nd.get("parent") is None or nd.get("parent") not in self.nodes]
|
||||
|
||||
def _target_state(self, node_id: str, rel: str):
|
||||
"""文件 rel 在 node_id 时应有的状态:节点 files 里有就用它,否则回退到
|
||||
baseline(CC 的 first-version)。返回 (known: bool, hash_or_None)。"""
|
||||
files = self.nodes[node_id]["files"]
|
||||
if rel in files:
|
||||
return True, files[rel]
|
||||
if rel in self.baseline:
|
||||
return True, self.baseline[rel]
|
||||
return False, None
|
||||
|
||||
def apply_code(self, node_id) -> list[tuple[str, str]]:
|
||||
"""把工作区文件还原到 node_id 的状态。`node_id=None` → 还原到 baseline
|
||||
(任何 checkpoint 之前的原始状态,用于「回退到根之前」)。
|
||||
|
||||
- **只动本 store 追踪过的路径**(self.tracked),绝不碰未记录文件(软保险①)。
|
||||
- restore 前先把这些路径的当前内容存进 redo 点(软保险②)——误覆盖也能找回。
|
||||
返回 [(rel, action)],action ∈ {restored, deleted}。"""
|
||||
if node_id is not None and node_id not in self.nodes:
|
||||
raise KeyError(node_id)
|
||||
|
||||
# 软保险②:redo 点(记录当前 = 即将被覆盖的状态)。
|
||||
redo_files: dict[str, str | None] = {}
|
||||
for rel in self.tracked:
|
||||
redo_files[rel] = self._snapshot(self._abs(rel))
|
||||
self._redo = {"from": self.head, "files": redo_files}
|
||||
|
||||
changed: list[tuple[str, str]] = []
|
||||
for rel in self.tracked:
|
||||
if node_id is None:
|
||||
known = rel in self.baseline
|
||||
target = self.baseline.get(rel)
|
||||
else:
|
||||
known, target = self._target_state(node_id, rel)
|
||||
if not known:
|
||||
continue
|
||||
ap = self._abs(rel)
|
||||
cur = self._snapshot(ap)
|
||||
if cur == target:
|
||||
continue
|
||||
if target is _ABSENT:
|
||||
try:
|
||||
os.remove(ap)
|
||||
changed.append((rel, "deleted"))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
os.makedirs(os.path.dirname(ap) or ".", exist_ok=True)
|
||||
with open(ap, "wb") as f:
|
||||
f.write(self._get_blob(target))
|
||||
changed.append((rel, "restored"))
|
||||
return changed
|
||||
|
||||
def diff(self, node_id: str) -> list[dict]:
|
||||
"""选中节点 vs 当前工作区,逐文件变更摘要(恢复到该节点会发生什么)。
|
||||
|
||||
返回 [{rel, action, insertions, deletions}],仅列出会变的文件。
|
||||
action: restore(覆盖)/ delete(删除)/ create(重建被删文件)。"""
|
||||
if node_id not in self.nodes:
|
||||
raise KeyError(node_id)
|
||||
out: list[dict] = []
|
||||
for rel in sorted(self.tracked):
|
||||
known, target = self._target_state(node_id, rel)
|
||||
if not known:
|
||||
continue
|
||||
cur = self._snapshot(self._abs(rel))
|
||||
if cur == target:
|
||||
continue
|
||||
old = self._text(cur) # 当前工作区内容
|
||||
new = self._text(target) # 该节点内容(恢复目标)
|
||||
ins = dele = 0
|
||||
for line in difflib.ndiff(old.splitlines(), new.splitlines()):
|
||||
if line.startswith("+ "):
|
||||
ins += 1
|
||||
elif line.startswith("- "):
|
||||
dele += 1
|
||||
if target is _ABSENT:
|
||||
action = "delete"
|
||||
elif cur is _ABSENT:
|
||||
action = "create"
|
||||
else:
|
||||
action = "restore"
|
||||
out.append({"rel": rel, "action": action, "insertions": ins, "deletions": dele})
|
||||
return out
|
||||
|
||||
def node_diff(self, node_id) -> list[dict]:
|
||||
"""选中节点 vs **父节点**的逐文件内容 diff(= 这次 checkpoint 改了什么)。
|
||||
返回 [{rel, old, new}](文本),仅含两者不同的文件;父缺失/文件不存在 → 空文本。
|
||||
供 UI 渲染逐行 diff(区别于 `diff()` 的"节点 vs 当前工作区"计数摘要)。"""
|
||||
if node_id not in self.nodes:
|
||||
return []
|
||||
nd = self.nodes[node_id]
|
||||
par = nd.get("parent")
|
||||
pf = self.nodes[par]["files"] if par in self.nodes else {}
|
||||
nf = nd.get("files") or {}
|
||||
out: list[dict] = []
|
||||
for rel in sorted(set(nf) | set(pf)):
|
||||
nh = nf.get(rel)
|
||||
# 「上一个状态」:父节点记录过就用它;父节点没有(本节点首次触碰该文件)→
|
||||
# 回退到 baseline(首次改前内容,CC first-version)。否则被 file_patch 修改的
|
||||
# 已有文件会因父节点无记录而被误显示成整文件新增,而非真正的逐行 diff。
|
||||
ph = pf[rel] if rel in pf else self.baseline.get(rel)
|
||||
if nh == ph:
|
||||
continue
|
||||
out.append({"rel": rel, "old": self._text(ph), "new": self._text(nh)})
|
||||
return out
|
||||
|
||||
def _text(self, h: str | None) -> str:
|
||||
if h is _ABSENT:
|
||||
return ""
|
||||
try:
|
||||
return self._get_blob(h).decode("utf-8", "replace")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# --------------------------------------------------------- delete + gc
|
||||
def delete_subtree(self, node_id: str) -> list[str]:
|
||||
"""删除节点及其整棵子树(不能删 HEAD 祖先路径上的节点 / root 由调用方把关)。
|
||||
返回被删的节点 id 列表。删后做一次 blob GC。"""
|
||||
if node_id not in self.nodes:
|
||||
raise KeyError(node_id)
|
||||
victims: list[str] = []
|
||||
|
||||
def collect(nid: str) -> None:
|
||||
victims.append(nid)
|
||||
for c in list(self.nodes[nid]["children"]):
|
||||
collect(c)
|
||||
|
||||
collect(node_id)
|
||||
parent = self.nodes[node_id]["parent"]
|
||||
if parent is not None:
|
||||
self.nodes[parent]["children"] = [
|
||||
c for c in self.nodes[parent]["children"] if c != node_id
|
||||
]
|
||||
for nid in victims:
|
||||
self.nodes.pop(nid, None)
|
||||
if node_id == self.root_id:
|
||||
self.root_id = None
|
||||
if self.head in victims:
|
||||
self.head = parent # HEAD 被删 → 退到父
|
||||
self._gc()
|
||||
self.save()
|
||||
return victims
|
||||
|
||||
def _gc(self) -> int:
|
||||
"""删除无人引用的 blob。返回删除数。"""
|
||||
referenced: set[str] = set()
|
||||
for node in self.nodes.values():
|
||||
for h in node["files"].values():
|
||||
if h is not _ABSENT:
|
||||
referenced.add(h)
|
||||
ch = node.get("conv") # 对话增量 blob 也是引用
|
||||
if ch:
|
||||
referenced.add(ch)
|
||||
for h in self.baseline.values():
|
||||
if h is not _ABSENT:
|
||||
referenced.add(h)
|
||||
if self._redo:
|
||||
for h in self._redo["files"].values():
|
||||
if h is not _ABSENT:
|
||||
referenced.add(h)
|
||||
removed = 0
|
||||
if not os.path.isdir(self.objects_dir):
|
||||
return 0
|
||||
for sub in os.listdir(self.objects_dir):
|
||||
d = os.path.join(self.objects_dir, sub)
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for h in os.listdir(d):
|
||||
if h not in referenced:
|
||||
try:
|
||||
os.remove(os.path.join(d, h))
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
# ------------------------------------------------------------ persistence
|
||||
def save(self) -> None:
|
||||
os.makedirs(self.root, exist_ok=True)
|
||||
payload = {
|
||||
"nodes": self.nodes,
|
||||
"head": self.head,
|
||||
"root": self.root_id,
|
||||
"seq": self._seq,
|
||||
"baseline": self.baseline,
|
||||
"tracked": sorted(self.tracked),
|
||||
}
|
||||
tmp = self.tree_path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, default=str)
|
||||
# 崩溃兜底:替换前把上一份完好的 tree.json 留作 .bak(load 损坏时回退它)。
|
||||
if os.path.exists(self.tree_path):
|
||||
try:
|
||||
shutil.copyfile(self.tree_path, self.tree_path + ".bak")
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, self.tree_path)
|
||||
|
||||
def load(self) -> None:
|
||||
"""加载树。主文件损坏/缺失 → 回退 .bak;都不行 → 留空 store(降级,不抛)。"""
|
||||
for path in (self.tree_path, self.tree_path + ".bak"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
except Exception:
|
||||
continue # 损坏 → 尝试下一个候选
|
||||
self.nodes = d.get("nodes", {})
|
||||
self.head = d.get("head")
|
||||
self.root_id = d.get("root")
|
||||
self._seq = d.get("seq", 0)
|
||||
self.baseline = d.get("baseline", {})
|
||||
self.tracked = set(d.get("tracked", []))
|
||||
return
|
||||
|
||||
def _new_id(self) -> str:
|
||||
self._seq += 1
|
||||
return f"v{self._seq}"
|
||||
|
||||
# ---------------------------------------------------------- session key
|
||||
@staticmethod
|
||||
def key_for_log(log_path: str) -> str:
|
||||
"""会话 key = log_path 的 basename 去扩展名,如 `model_responses_123456`。
|
||||
这是 GA 唯一稳定、/continue 据以识别会话的身份(task_dir 是 PID 级、重启即变,
|
||||
不能用)。"""
|
||||
return os.path.splitext(os.path.basename(log_path))[0]
|
||||
|
||||
@classmethod
|
||||
def for_log(cls, temp_dir: str, log_path: str, cwd: str) -> "RewindStore":
|
||||
"""按 GA 的 log_path 落到 `temp/.ga_rewind/<key>/`。"""
|
||||
root = os.path.join(temp_dir, ".ga_rewind", cls.key_for_log(log_path))
|
||||
return cls(root, cwd)
|
||||
|
||||
def resume_from(self, old_root: str) -> bool:
|
||||
"""/continue 续接:把旧会话的 rewind 目录接到本 store(贴近 CC 的
|
||||
copyFileHistoryForResume)。
|
||||
|
||||
GA 每个新 agent 生成新 log_path → 新 key,旧会话的树/blob 默认看不到;
|
||||
这里在新 key 下重建:① 接管旧 tree.json(仅当本 store 尚为空);
|
||||
② blob 内容寻址,直接 hardlink(同名即同内容,跳过;失败回退 copy)。
|
||||
返回是否接管成功。"""
|
||||
if not os.path.isdir(old_root) or os.path.abspath(old_root) == self.root:
|
||||
return False
|
||||
old_tree = os.path.join(old_root, "tree.json")
|
||||
if not self.nodes and os.path.exists(old_tree):
|
||||
try:
|
||||
with open(old_tree, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
self.nodes = d.get("nodes", {})
|
||||
self.head = d.get("head")
|
||||
self.root_id = d.get("root")
|
||||
self._seq = d.get("seq", 0)
|
||||
self.baseline = d.get("baseline", {})
|
||||
self.tracked = set(d.get("tracked", []))
|
||||
except Exception:
|
||||
return False
|
||||
old_objects = os.path.join(old_root, "objects")
|
||||
if os.path.isdir(old_objects):
|
||||
for sub in os.listdir(old_objects):
|
||||
sd = os.path.join(old_objects, sub)
|
||||
if not os.path.isdir(sd):
|
||||
continue
|
||||
dd = os.path.join(self.objects_dir, sub)
|
||||
os.makedirs(dd, exist_ok=True)
|
||||
for h in os.listdir(sd):
|
||||
dst = os.path.join(dd, h)
|
||||
if os.path.exists(dst):
|
||||
continue # 内容寻址:同名即同内容
|
||||
src = os.path.join(sd, h)
|
||||
try:
|
||||
os.link(src, dst) # hardlink 不占额外空间
|
||||
except OSError:
|
||||
try:
|
||||
shutil.copyfile(src, dst) # 跨盘/不支持 → 回退 copy
|
||||
except OSError:
|
||||
pass
|
||||
if self.nodes:
|
||||
self._ensure_origin() # 旧树若无 origin,迁移补上
|
||||
self.save()
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ views
|
||||
def linear_path(self) -> list[str]:
|
||||
"""root → HEAD 的线性路径(节点 id),供 /rewind 时间线展示。"""
|
||||
return self.path_to(self.head)
|
||||
|
||||
def path_to(self, node_id) -> list[str]:
|
||||
"""root → node_id 的节点 id 列表(含两端)。node_id 缺失 → []。"""
|
||||
if node_id is None or node_id not in self.nodes:
|
||||
return []
|
||||
chain: list[str] = []
|
||||
cur: str | None = node_id
|
||||
while cur is not None and cur in self.nodes:
|
||||
chain.append(cur)
|
||||
cur = self.nodes[cur].get("parent")
|
||||
chain.reverse()
|
||||
return chain
|
||||
|
||||
# ----------------------------------------------------------- conversation
|
||||
def _node_conv(self, node_id) -> list:
|
||||
"""单节点的对话增量(list)。无 conv / 读失败 → [](容错降级)。"""
|
||||
h = self.nodes.get(node_id, {}).get("conv")
|
||||
if not h:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(self._get_blob(h).decode("utf-8"))
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def rebuild_history(self, node_id) -> list:
|
||||
"""沿 root→node_id 拼接各节点对话增量,整体重建 `backend.history`。
|
||||
|
||||
这是「树=真相源」的恢复入口:选任意节点(含旁支)都能精确还原其对话,
|
||||
不依赖内存里那条线性 history。origin / 无 conv 的节点贡献空增量。"""
|
||||
hist: list = []
|
||||
for nid in self.path_to(node_id):
|
||||
hist.extend(self._node_conv(nid))
|
||||
return hist
|
||||
|
||||
# ----------------------------------------------------- 对账(防外部改写灾难)
|
||||
@staticmethod
|
||||
def _msg_user_text(msg) -> str:
|
||||
"""真实用户提问文本;非 user / 纯 tool_result 回填 → ""(不算提问边界)。"""
|
||||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||||
return ""
|
||||
c = msg.get("content")
|
||||
if isinstance(c, str):
|
||||
return c
|
||||
if isinstance(c, list):
|
||||
if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in c):
|
||||
return ""
|
||||
return " ".join(b.get("text", "") for b in c
|
||||
if isinstance(b, dict) and b.get("type") == "text")
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _turn_sig(cls, msgs) -> list:
|
||||
"""一段消息的「对话身份」= 其中真实用户提问的文本序列(跳过 tool_result 轮,
|
||||
故不含注入的易变内容)。用于 reconcile 判两段是否同一对话。"""
|
||||
return [t for t in (cls._msg_user_text(m).strip() for m in msgs) if t]
|
||||
|
||||
def reconcile(self, history: list) -> int:
|
||||
"""把 live history 里树尚未记录的尾部轮次吸收成 conv-only 节点,使树追平日志。
|
||||
供「带 worldline 的 UI」在打开世界线 / 接管日志前调用 —— 别的(不更新树的)
|
||||
UI 往同一日志追加后,若不对账,树会滞后;一旦在滞后状态 rewind,
|
||||
`rewrite_projection` 会拿陈旧树回写、**抹掉那些外部轮次**(灾难性丢失)。
|
||||
对账后树恒 ⊇ 日志,故 rewind 物理上不可能 clobber 未见过的轮次。
|
||||
|
||||
**安全闸**:仅当树 HEAD 路径与 history 前缀是「同一段对话」才在其上 append;
|
||||
否则判为分歧(用户问了不同的问题),HEAD 退回 origin 另起一条顶层 trunk 容纳
|
||||
live history,旧树保留为旁支、绝不拿它回写日志。空树 = n=0 特例(吸收全部,
|
||||
即旧 _bootstrap_conv_tree)。幂等:无尾巴 → no-op,可任意重复调用。返回吸收条数。
|
||||
|
||||
判「同一段对话」用**用户提问序列**(`_turn_sig`)而非逐条全等:消息里会被注入
|
||||
易变内容(如 `[WORKING MEMORY]`,只在 tool_result 轮),同一段对话在不同时刻
|
||||
捕获并不逐字节相等;逐条全等会把它误判成分歧、复制出一整条重复 trunk。提问
|
||||
文本在 tool_result 轮之外、稳定不变,是可靠的对话身份。"""
|
||||
history = list(history or [])
|
||||
self._ensure_origin()
|
||||
head = self.head if self.head in self.nodes else self.root_id
|
||||
known = self.rebuild_history(head)
|
||||
n = len(known)
|
||||
if self._turn_sig(history[:n]) != self._turn_sig(known):
|
||||
# 分歧(提问序列不同):不信任陈旧树。退回空起点,后续 append 只反映 live history。
|
||||
self.rewind_head(self.root_id)
|
||||
n = len(self.rebuild_history(self.root_id)) # origin 无 conv → 0
|
||||
if len(history) <= n:
|
||||
return 0
|
||||
# 按用户提问边界切尾巴 [n, len);逐段 commit(history=history[:段末]),
|
||||
# commit 内部 delta = history[parent.hist_len:段末]。清 _touched 保证纯 conv-only。
|
||||
starts = [i for i in range(n, len(history))
|
||||
if self._msg_user_text(history[i]).strip()]
|
||||
if not starts or starts[0] > n:
|
||||
starts = [n] + starts
|
||||
self._touched.clear()
|
||||
absorbed = 0
|
||||
for p, s in enumerate(starts):
|
||||
end = starts[p + 1] if p + 1 < len(starts) else len(history)
|
||||
title = next((self._msg_user_text(history[i]).strip()
|
||||
for i in range(s, end)
|
||||
if self._msg_user_text(history[i]).strip()), "")
|
||||
title = (title or "(外部续接)").replace("\n", " ").strip()[:80]
|
||||
self.commit(title or "(外部续接)", kind="edit", history=list(history[:end]))
|
||||
absorbed += end - s
|
||||
return absorbed
|
||||
|
||||
def first_user_message(self, node_id) -> dict | None:
|
||||
"""node_id 对话增量里的首条 `role==user` 消息(原始 dict);无则 None。
|
||||
|
||||
供 rewind 后精确 prefill(取「该提问本身」,不靠会漂的绝对下标)。文本提取
|
||||
交调用方(消息内容格式是 GA backend 私有的)。"""
|
||||
for msg in self._node_conv(node_id):
|
||||
if isinstance(msg, dict) and msg.get("role") == "user":
|
||||
return msg
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 世界线视图模型 + 压缩 + 导航(从 rewind_tree_view.py 抽出的 UI 无关后端)
|
||||
# ============================================================================
|
||||
# 节点**语义**(字形 + 文字标签);**呈现色由前端决定**(颜色不属于后端)。
|
||||
KIND_GLYPH = {"current": "◉", "rewind": "↩", "origin": "●"} # ◉=当前所在;●=普通/根
|
||||
KIND_LABEL = {"current": "当前位置", "rewind": "回退来源", "origin": "会话起点"}
|
||||
|
||||
|
||||
def kind_glyph(kind: str) -> str:
|
||||
return KIND_GLYPH.get(kind, "●")
|
||||
|
||||
|
||||
def kind_label(kind: str) -> str:
|
||||
return KIND_LABEL.get(kind, "")
|
||||
|
||||
|
||||
def rel_time(ago_s) -> str:
|
||||
if ago_s is None:
|
||||
return ""
|
||||
if ago_s < 60:
|
||||
return "刚刚"
|
||||
m = ago_s // 60
|
||||
if m < 60:
|
||||
return f"{m} 分钟前"
|
||||
h = m // 60
|
||||
if h < 24:
|
||||
return f"{h} 小时前"
|
||||
return f"{h // 24} 天前"
|
||||
|
||||
|
||||
def files_summary(files: List[str]) -> str:
|
||||
if not files:
|
||||
return "无变更"
|
||||
if len(files) <= 3:
|
||||
return "、".join(files)
|
||||
return "、".join(files[:3]) + f" (+{len(files) - 3})"
|
||||
|
||||
|
||||
_TITLE_MAX_CELLS = 40
|
||||
|
||||
|
||||
def ellipsize(s: str, max_cells: int = _TITLE_MAX_CELLS) -> str:
|
||||
"""按显示宽度(中文算 2 格)截断,超长加 `…`。用于左树/右上的长提问标题。"""
|
||||
s = (s or "").replace("\n", " ").strip()
|
||||
if cell_len(s) <= max_cells:
|
||||
return s
|
||||
return set_cell_size(s, max(1, max_cells - 1)).rstrip() + "…"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- 数据模型
|
||||
@dataclass
|
||||
class CheckpointNode:
|
||||
id: str
|
||||
title: str
|
||||
parent_id: Optional[str] = None
|
||||
children: List[str] = field(default_factory=list)
|
||||
kind: str = "edit"
|
||||
files: List[str] = field(default_factory=list)
|
||||
ago: Optional[int] = 0
|
||||
|
||||
|
||||
class CheckpointTree:
|
||||
"""只读视图树(供压缩/渲染);从 RewindStore 构造。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.nodes: Dict[str, CheckpointNode] = {}
|
||||
self.root_id: Optional[str] = None
|
||||
|
||||
|
||||
def _changed_files(store, node_id) -> List[str]:
|
||||
import os
|
||||
nd = store.nodes[node_id]
|
||||
par = nd.get("parent")
|
||||
pf = store.nodes[par]["files"] if par in store.nodes else {}
|
||||
out: List[str] = []
|
||||
for k, v in nd["files"].items():
|
||||
if pf.get(k) != v:
|
||||
b = os.path.basename(k)
|
||||
if b not in out:
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
def tree_from_store(store, now: float) -> CheckpointTree:
|
||||
"""把 RewindStore 的真实树投影成只读 CheckpointTree;HEAD 标 current。"""
|
||||
t = CheckpointTree()
|
||||
for nid, nd in store.nodes.items():
|
||||
t.nodes[nid] = CheckpointNode(
|
||||
id=nid,
|
||||
title=nd.get("title") or "(空)",
|
||||
parent_id=nd.get("parent"),
|
||||
children=[c for c in nd.get("children", []) if c in store.nodes],
|
||||
kind="current" if nid == store.head else nd.get("kind", "edit"),
|
||||
files=_changed_files(store, nid),
|
||||
ago=int(max(0, now - nd.get("created", now))),
|
||||
)
|
||||
t.root_id = store.root_id
|
||||
return t
|
||||
|
||||
|
||||
# --------------------------------------------- 压缩(强制折叠线性段,§4.2)
|
||||
@dataclass
|
||||
class DisplayNode:
|
||||
key: int
|
||||
kind: str # 'real' | 'fold'
|
||||
depth: int = 0
|
||||
parent_key: Optional[int] = None
|
||||
children: List[int] = field(default_factory=list)
|
||||
node_id: Optional[str] = None
|
||||
seg: List[str] = field(default_factory=list)
|
||||
end_id: Optional[str] = None
|
||||
|
||||
|
||||
class CompressedTree:
|
||||
def __init__(self, tree: CheckpointTree) -> None:
|
||||
self.tree = tree
|
||||
self.disp: Dict[int, DisplayNode] = {}
|
||||
self.roots: List[int] = [] # 顶层显示节点 keys(通常一个 = origin)
|
||||
self._k = 0
|
||||
# 顶层节点(origin「会话起点」)常驻独立显示作锚点;它下面的真实对话链
|
||||
# 照常折叠(§4.2)。origin 是「第一轮对话之前」的空状态,不是某轮对话,
|
||||
# 所以独立显示语义清晰、不会像"第一轮对话被强行拎出"那样让人困惑。
|
||||
for top in self._top_level():
|
||||
self._build_root_node(top)
|
||||
|
||||
def _top_level(self) -> List[str]:
|
||||
return [nid for nid, n in self.tree.nodes.items()
|
||||
if n.parent_id is None or n.parent_id not in self.tree.nodes]
|
||||
|
||||
def _new_key(self) -> int:
|
||||
k = self._k
|
||||
self._k += 1
|
||||
return k
|
||||
|
||||
def _build_root_node(self, root_id: str) -> int:
|
||||
key = self._new_key()
|
||||
self.disp[key] = DisplayNode(key=key, kind="real", node_id=root_id, depth=0)
|
||||
self.roots.append(key)
|
||||
for child_id in self.tree.nodes[root_id].children:
|
||||
self._build_chain(child_id, 1, key)
|
||||
return key
|
||||
|
||||
def _build_chain(self, start_id: str, depth: int, parent: Optional[int]) -> int:
|
||||
seg = [start_id]
|
||||
cur = start_id
|
||||
while len(self.tree.nodes[cur].children) == 1:
|
||||
cur = self.tree.nodes[cur].children[0]
|
||||
seg.append(cur)
|
||||
end = seg[-1]
|
||||
key = self._new_key()
|
||||
if len(seg) == 1:
|
||||
self.disp[key] = DisplayNode(key=key, kind="real", node_id=end,
|
||||
depth=depth, parent_key=parent)
|
||||
else:
|
||||
self.disp[key] = DisplayNode(key=key, kind="fold", depth=depth,
|
||||
parent_key=parent, seg=list(seg), end_id=end)
|
||||
if parent is None:
|
||||
self.roots.append(key)
|
||||
else:
|
||||
self.disp[parent].children.append(key)
|
||||
for child_id in self.tree.nodes[end].children:
|
||||
self._build_chain(child_id, depth + 1, key)
|
||||
return key
|
||||
|
||||
def flatten(self) -> List[int]:
|
||||
order: List[int] = []
|
||||
|
||||
def dfs(k: int) -> None:
|
||||
order.append(k)
|
||||
for c in self.disp[k].children:
|
||||
dfs(c)
|
||||
|
||||
for r in self.roots:
|
||||
dfs(r)
|
||||
return order
|
||||
|
||||
def end_node(self, key: int) -> CheckpointNode:
|
||||
d = self.disp[key]
|
||||
return self.tree.nodes[d.node_id if d.kind == "real" else d.end_id]
|
||||
|
||||
def label(self, key: int) -> str:
|
||||
# 真实/折叠都只放末端标题(截断);折叠的 [N nodes] 标记放在 glyph 位(标题前)。
|
||||
d = self.disp[key]
|
||||
nid = d.node_id if d.kind == "real" else d.end_id
|
||||
return ellipsize(self.tree.nodes[nid].title)
|
||||
|
||||
def glyph(self, key: int) -> str:
|
||||
# 折叠节点开头用 [N nodes] 替代原来的 … 省略号(→ "[3 nodes] 标题");
|
||||
# 真实节点仍用语义字形(● / ↩ / ◉)。
|
||||
d = self.disp[key]
|
||||
if d.kind == "fold":
|
||||
return f"[{len(d.seg)} nodes]"
|
||||
return kind_glyph(self.tree.nodes[d.node_id].kind)
|
||||
|
||||
|
||||
# ----------------------------------------------------- 导航(纯计算)
|
||||
def _order_depths(ct: CompressedTree) -> List[tuple[int, int]]:
|
||||
return [(k, ct.disp[k].depth) for k in ct.flatten()]
|
||||
|
||||
|
||||
def next_same_depth(order, sel, direction):
|
||||
idx = next(i for i, (k, _) in enumerate(order) if k == sel)
|
||||
depth = order[idx][1]
|
||||
same = [i for i, (_, d) in enumerate(order) if d == depth]
|
||||
pos = same.index(idx)
|
||||
return order[same[(pos + direction) % len(same)]][0]
|
||||
|
||||
|
||||
def nearest_depth_node(order, sel, delta):
|
||||
idx = next(i for i, (k, _) in enumerate(order) if k == sel)
|
||||
target = order[idx][1] + delta
|
||||
if target < 0:
|
||||
return sel
|
||||
cand = [i for i, (_, d) in enumerate(order) if d == target]
|
||||
if not cand:
|
||||
return sel
|
||||
if delta > 0:
|
||||
best = min(cand, key=lambda i: (abs(i - idx), 0 if i >= idx else 1, i))
|
||||
else:
|
||||
best = min(cand, key=lambda i: (abs(i - idx), 0 if i <= idx else 1, -i))
|
||||
return order[best][0]
|
||||
|
||||
|
||||
def parent_sibling_first_child(ct, sel, direction):
|
||||
d = ct.disp[sel]
|
||||
if d.parent_key is None:
|
||||
return sel
|
||||
parent = ct.disp[d.parent_key]
|
||||
if parent.parent_key is None:
|
||||
return sel
|
||||
siblings = ct.disp[parent.parent_key].children
|
||||
if parent.key not in siblings or len(siblings) <= 1:
|
||||
return sel
|
||||
start = siblings.index(parent.key)
|
||||
step = 1 if direction >= 0 else -1
|
||||
for off in range(1, len(siblings)):
|
||||
sib = ct.disp[siblings[(start + step * off) % len(siblings)]]
|
||||
if sib.children:
|
||||
return sib.children[0]
|
||||
return sel
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 恢复编排(UI 无关):算出回退后的对话/文件/prefill 并落地,前端只刷新自己的显示
|
||||
# ============================================================================
|
||||
def user_msg_text(entry) -> str:
|
||||
"""从一条 user 消息里取出用户可见文本(content 为 str 或 text block 列表)。"""
|
||||
c = entry.get("content") if isinstance(entry, dict) else None
|
||||
if isinstance(c, str):
|
||||
return c
|
||||
if isinstance(c, list):
|
||||
parts = [b.get("text", "") for b in c
|
||||
if isinstance(b, dict) and b.get("type") == "text"]
|
||||
return "\n".join(p for p in parts if p)
|
||||
return ""
|
||||
|
||||
|
||||
def render_native_history(history, ts=None):
|
||||
"""Inverse of `continue_cmd._parse_native_history`: render a `backend.history`
|
||||
(alternating user/assistant) back into the native `=== Prompt/Response ===`
|
||||
log text. Lives here (not continue_cmd) because the rewind tree is its only
|
||||
caller — it rewrites `model_responses_<key>.txt` as a linear projection of
|
||||
the current HEAD path so /continue + other UIs get a clean single-branch view
|
||||
(the TUI itself reads only the tree). Round-trip holds:
|
||||
`_parse_native_history(_pairs(render_native_history(h))) == h`.
|
||||
|
||||
Byte-for-byte matches llmcore's `_write_llm_log` for the tool backend:
|
||||
Prompt = `json.dumps(merged, ensure_ascii=False, indent=2)`; Response =
|
||||
`str(content_blocks)` which for a list equals `repr(...)` (survives
|
||||
True/False/None that JSON would emit as true/false/null and break
|
||||
`ast.literal_eval`). Only complete (user, assistant) pairs are emitted."""
|
||||
ts = ts or datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
out, i, n = [], 0, len(history)
|
||||
while i < n:
|
||||
u = history[i]
|
||||
if isinstance(u, dict) and u.get('role') == 'user' and i + 1 < n:
|
||||
a = history[i + 1]
|
||||
if isinstance(a, dict) and a.get('role') == 'assistant':
|
||||
out.append(f"=== Prompt === {ts}\n{json.dumps(u, ensure_ascii=False, indent=2)}\n\n")
|
||||
out.append(f"=== Response === {ts}\n{a.get('content')!r}\n\n")
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def rewrite_projection(store, target, log_path: str) -> bool:
|
||||
"""把 `model_responses_<key>.txt` 重写成 root→target 的线性投影(native
|
||||
`=== Prompt/Response ===` 格式,`continue_cmd._parse_native_history` 可解析)。
|
||||
|
||||
给其他 UI + /continue 的兼容视角;消除 rewind/fork 后多分支混排。故障静默。"""
|
||||
if store is None or not log_path:
|
||||
return False
|
||||
try:
|
||||
text = render_native_history(store.rebuild_history(target))
|
||||
# 回退到「会话起点」(origin)时 history 为空 → 日志被写空(0 字节)。这是【硬】回退:
|
||||
# 日志恒等于树 HEAD,无错位,reconcile 不会复制分支。空日志会被普通 UI 的
|
||||
# list_sessions(sz<32) 无害跳过;带 worldline 的 UI 靠 list_sessions(rewind_root=...)
|
||||
# 的树感知发现 + continue_inplace(allow_empty=True) 仍能找回并恢复(空对话 + 重连树)。
|
||||
tmp = log_path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8", errors="replace") as f:
|
||||
f.write(text)
|
||||
os.replace(tmp, log_path)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def restore_plan(store, node_id, mode: str = "both", to: str = "before",
|
||||
log_path: str = "") -> Optional[dict]:
|
||||
"""UI 无关的回退编排。`to`:
|
||||
- **before**(选中段内某节点):回到该提问**之前**(连该节点一起清除),prefill 其提问;
|
||||
target = parent(node) / origin。
|
||||
- **at**(末尾占位项):在该节点处**继续**(HEAD→node,不清除、无 prefill)。
|
||||
`mode`: both/conv/code。会就地:重建对话(返回 history,调用方自行赋给 backend)、
|
||||
`apply_code`(还原文件,前自动留 redo 点)、移 HEAD、重写投影日志。
|
||||
|
||||
返回 None(无效) 或 dict:
|
||||
{history: list|None(仅 conv 变更时), changed: [(rel,action)], prefill: str,
|
||||
at_origin: bool, target: str, title: str, to: str}
|
||||
—— 前端据此:赋 backend.history、重建界面消息、prefill 输入框、刷新。"""
|
||||
if store is None or not node_id or node_id not in store.nodes:
|
||||
return None
|
||||
nd = store.nodes[node_id]
|
||||
prefill = ""
|
||||
if to == "at":
|
||||
target = node_id
|
||||
at_origin = nd.get("kind") == "origin"
|
||||
else:
|
||||
parent = nd.get("parent")
|
||||
parent = parent if parent in store.nodes else None
|
||||
target = parent if parent is not None else node_id
|
||||
at_origin = parent is None
|
||||
if not at_origin:
|
||||
um = store.first_user_message(node_id)
|
||||
prefill = (user_msg_text(um) if um else "") or nd.get("title", "")
|
||||
|
||||
history = None
|
||||
if mode in ("both", "conv"):
|
||||
history = store.rebuild_history(target)
|
||||
changed = []
|
||||
if mode in ("both", "code"):
|
||||
try:
|
||||
changed = store.apply_code(target)
|
||||
except Exception:
|
||||
changed = []
|
||||
store.rewind_head(target)
|
||||
if mode in ("both", "conv") and log_path:
|
||||
rewrite_projection(store, target, log_path)
|
||||
return {
|
||||
"history": history,
|
||||
"changed": changed,
|
||||
"prefill": prefill,
|
||||
"at_origin": at_origin,
|
||||
"target": target,
|
||||
"title": nd.get("title", ""),
|
||||
"to": to,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import sys, os, re, json, time, threading, importlib
|
||||
import sys, os, re, json, time, threading, importlib, webbrowser
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import tempfile, traceback, subprocess, itertools, collections, difflib, shutil
|
||||
@@ -108,14 +108,11 @@ def first_init_driver():
|
||||
global driver
|
||||
from TMWebDriver import TMWebDriver
|
||||
driver = TMWebDriver()
|
||||
for i in range(20):
|
||||
time.sleep(1)
|
||||
for i in range(7):
|
||||
time.sleep(2)
|
||||
sess = driver.get_all_sessions()
|
||||
if len(sess) > 0: break
|
||||
if len(sess) == 0: return
|
||||
if len(sess) == 1:
|
||||
#driver.newtab()
|
||||
time.sleep(3)
|
||||
if i == 4: webbrowser.open("https://example.com")
|
||||
|
||||
def web_scan(tabs_only=False, switch_tab_id=None, text_only=False, maxlen=35000):
|
||||
"""获取当前页面的简化HTML内容和标签页列表。注意:简化过程会过滤边栏、浮动元素等非主体内容。
|
||||
@@ -244,9 +241,9 @@ def file_read(path, start=1, keyword=None, count=200, show_linenos=True):
|
||||
except FileNotFoundError:
|
||||
msg = f"Error: File not found: {path}"
|
||||
try:
|
||||
tgt = os.path.basename(path); scan = os.path.dirname(os.path.dirname(os.path.abspath(path)))
|
||||
roots = [scan] + [d for d in _read_dirs if not d.startswith(scan)]
|
||||
cands = list(itertools.islice((c for base in roots for c in _scan_files(base)), 2000))
|
||||
tgt = os.path.basename(path); parent = os.path.dirname(os.path.abspath(path)); scan = os.path.dirname(parent)
|
||||
roots = [parent, scan] + [d for d in _read_dirs if not d.startswith(scan)]
|
||||
cands = list(dict.fromkeys(itertools.islice((c for base in roots for c in _scan_files(base)), 2000)))
|
||||
top = sorted([(difflib.SequenceMatcher(None, tgt.lower(), c[0].lower()).ratio(), c) for c in cands[:2000]], key=lambda x: -x[0])[:5]
|
||||
top = [(s, c) for s, c in top if s > 0.3]
|
||||
if top: msg += "\n\nDid you mean:\n" + "\n".join(f" {c[1]} ({s:.0%})" for s, c in top)
|
||||
@@ -503,7 +500,7 @@ class GenericAgentHandler(BaseHandler):
|
||||
if remaining == 0:
|
||||
self._exit_plan_mode(); yield "[Info] Plan完成:plan.md中0个[ ]残留,退出plan模式。\n"
|
||||
|
||||
yield "[Info] Final response to user.\n"
|
||||
#yield "[Info] Final response to user.\n"
|
||||
return StepOutcome(response, next_prompt=None)
|
||||
|
||||
def do_start_long_term_update(self, args, response):
|
||||
@@ -556,7 +553,7 @@ class GenericAgentHandler(BaseHandler):
|
||||
else:
|
||||
tc = tool_calls[0]; tool_name, args = tc['tool_name'], tc['args'] # at least one because no_tool
|
||||
clean_args = {k: v for k, v in args.items() if not k.startswith('_')}
|
||||
summary = f"调用工具{tool_name}, args: {clean_args}"
|
||||
summary = f"{tool_name}, args: {clean_args}"
|
||||
if tool_name == 'no_tool': summary = "直接回答了用户问题"
|
||||
next_prompt += "\n\n\n[SYSTEM] 必须在回复文本中包含<summary>!\n\n"
|
||||
summary = smart_format(summary.replace('\n', ''), max_str_len=40)
|
||||
@@ -564,15 +561,17 @@ class GenericAgentHandler(BaseHandler):
|
||||
self.history_info.append(f'[Agent] {summary}')
|
||||
_plan = self._in_plan_mode()
|
||||
|
||||
if turn % 75 == 0 and (not _plan):
|
||||
next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。必须总结情况进行ask_user,不允许继续重试。"
|
||||
if turn % 175 == 0 and (not _plan):
|
||||
next_prompt += f"\n\n[DANGER] Turn {turn}. Must call ask_user to summarize progress and get direction. No more blind retries."
|
||||
elif turn % 7 == 0:
|
||||
next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。禁止无效重试。若无有效进展,必须切换策略:1. 探测物理边界 2. 请求用户协助。如有需要,可调用 update_working_checkpoint 保存关键上下文。"
|
||||
next_prompt += f"\n\n[SYSTEM] Turn {turn}. Call update_working_checkpoint to save key context. Stop ineffective retries; if no progress, switch strategy: 1) Probe physical boundaries 2) **Re-read relevant SOPs**"
|
||||
elif turn % 25 == 0:
|
||||
next_prompt += f"\n\n[SYSTEM] Turn {turn}. Write checkpoints/key findings/tried approaches to a file for future reference. Avoid losing critical info."
|
||||
elif turn % 10 == 0: next_prompt += get_global_memory()
|
||||
|
||||
if _plan and turn >= 10 and turn % 5 == 0:
|
||||
next_prompt = f"[Plan Hint] 正在计划模式。必须 file_read({_plan}) 确认当前步骤,回复开头引用:📌 当前步骤:...\n\n" + next_prompt
|
||||
if _plan and turn >= 120: next_prompt += f"\n\n[DANGER] Plan模式已运行 {turn} 轮,已达上限。必须 ask_user 汇报进度并确认是否继续。"
|
||||
if _plan and turn >= 190: next_prompt += f"\n\n[DANGER] Plan模式已运行 {turn} 轮,已达上限。必须 ask_user 汇报进度并确认是否继续。"
|
||||
|
||||
injkeyinfo = consume_file(self.parent.task_dir, '_keyinfo')
|
||||
injprompt = consume_file(self.parent.task_dir, '_intervene')
|
||||
|
||||
+8
-42
@@ -1,6 +1,6 @@
|
||||
import webview, threading, subprocess, sys, time, os, ctypes, atexit, socket, random
|
||||
|
||||
WINDOW_WIDTH, WINDOW_HEIGHT, RIGHT_PADDING, TOP_PADDING = 600, 900, 0, 100
|
||||
WINDOW_WIDTH, WINDOW_HEIGHT, RIGHT_PADDING, TOP_PADDING = 1200, 900, 0, 50
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
frontends_dir = os.path.join(script_dir, "frontends")
|
||||
@@ -22,31 +22,6 @@ def start_streamlit(port):
|
||||
proc = subprocess.Popen(cmd)
|
||||
atexit.register(proc.kill)
|
||||
|
||||
def inject(text):
|
||||
window.evaluate_js(f"""
|
||||
const textarea = document.querySelector('textarea[data-testid="stChatInputTextArea"]');
|
||||
if (textarea) {{
|
||||
// 1. 用原生 setter 设置值(绕过 React)
|
||||
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
||||
nativeTextAreaValueSetter.call(textarea, {repr(text)});
|
||||
// 2. 触发 React 的 input 事件
|
||||
textarea.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
// 3. 触发 change 事件(有些组件需要)
|
||||
textarea.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||||
// 4. 延迟提交
|
||||
setTimeout(() => {{
|
||||
const btn = document.querySelector('[data-testid="stChatInputSubmitButton"]');
|
||||
if (btn) {{btn.click();console.log('Submitted:', {repr(text)});}}
|
||||
}}, 200);
|
||||
}}""")
|
||||
|
||||
def get_last_reply_time():
|
||||
last = window.evaluate_js("""
|
||||
const el = document.getElementById('last-reply-time');
|
||||
el ? parseInt(el.textContent) : 0;
|
||||
""") or 0
|
||||
return last or int(time.time())
|
||||
|
||||
PASTE_HOOK_JS = """if (!window._pasteHooked) { window._pasteHooked = true;
|
||||
document.addEventListener('paste', e => {
|
||||
const items = e.clipboardData?.items; if (!items) return;
|
||||
@@ -65,21 +40,13 @@ PASTE_HOOK_JS = """if (!window._pasteHooked) { window._pasteHooked = true;
|
||||
}, true);
|
||||
}"""
|
||||
|
||||
def idle_monitor():
|
||||
last_trigger_time = 0
|
||||
def paste_hook_injector():
|
||||
"""注入剪贴板粘贴图片钩子,等窗口加载后执行一次,之后每60秒补注入防页面刷新丢失"""
|
||||
time.sleep(5) # 等 webview 加载
|
||||
while True:
|
||||
time.sleep(5)
|
||||
try:
|
||||
window.evaluate_js(PASTE_HOOK_JS)
|
||||
now = time.time()
|
||||
if now - last_trigger_time < 120: continue
|
||||
last_reply = get_last_reply_time()
|
||||
if now - last_reply > 1800:
|
||||
print('[Idle Monitor] Detected idle state, injecting task...')
|
||||
inject("[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。")
|
||||
last_trigger_time = now
|
||||
except Exception as e:
|
||||
print(f'[Idle Monitor] Error: {e}')
|
||||
try: window.evaluate_js(PASTE_HOOK_JS)
|
||||
except: pass
|
||||
time.sleep(60)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
@@ -140,8 +107,7 @@ if __name__ == '__main__':
|
||||
print('[Launch] Task Scheduler started (duplicate prevented by scheduler port lock)')
|
||||
else: print('[Launch] Task Scheduler not enabled (--sched)')
|
||||
|
||||
monitor_thread = threading.Thread(target=idle_monitor, daemon=True)
|
||||
monitor_thread.start()
|
||||
threading.Thread(target=paste_hook_injector, daemon=True).start()
|
||||
if os.name == 'nt':
|
||||
screen_width = get_screen_width()
|
||||
x_pos = screen_width - WINDOW_WIDTH - RIGHT_PADDING
|
||||
|
||||
+30
-18
@@ -1,7 +1,7 @@
|
||||
import os, json, re, time, requests, sys, threading, urllib3, base64, importlib, uuid
|
||||
import os, json, re, time, requests, sys, threading, urllib3, base64, importlib, uuid, pathlib
|
||||
from datetime import datetime
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
_RESP_CACHE_KEY = str(uuid.uuid4())
|
||||
_RESP_CACHE_KEY = str(uuid.uuid4()); _RESP_CODEX_KEY = str(uuid.uuid4())
|
||||
_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if _ROOT not in sys.path: sys.path.append(_ROOT)
|
||||
|
||||
@@ -17,7 +17,9 @@ def _load_mykeys():
|
||||
raise Exception(f'[ERROR] mykey.py has syntax error: {e}') from e
|
||||
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mykey.json')
|
||||
if not os.path.exists(p): raise Exception('[ERROR] mykey.py not found in sys.path and mykey.json not found. Run "python configure_mykey.py" or copy mykey_template.py to mykey.py and fill in your keys.')
|
||||
with open(_mykey_path := p, encoding='utf-8') as f: return json.load(f)
|
||||
with open(_mykey_path := p, encoding='utf-8') as f: mk = json.load(f)
|
||||
if isinstance(mk, dict) and 'remote_url' in mk: return requests.get(mk['remote_url'], timeout=10).json()
|
||||
return mk
|
||||
|
||||
_mykey_path = _mykey_mtime = None
|
||||
def reload_mykeys():
|
||||
@@ -186,7 +188,8 @@ def _parse_claude_sse(resp_lines):
|
||||
content_blocks.append(current_block); current_block = None
|
||||
if warn:
|
||||
print(f"[WARN] {warn.strip()}")
|
||||
content_blocks.append({"type": "text", "text": warn}); yield warn
|
||||
insert_at = next((i for i,b in enumerate(content_blocks) if b.get("type") == "tool_use"), len(content_blocks))
|
||||
content_blocks.insert(insert_at, {"type": "text", "text": warn}); yield warn
|
||||
return content_blocks
|
||||
|
||||
def _try_parse_tool_args(raw):
|
||||
@@ -271,8 +274,8 @@ def _parse_openai_sse(resp_lines, api_mode="chat_completions"):
|
||||
except: continue
|
||||
ch = (evt.get("choices") or [{}])[0]
|
||||
delta = ch.get("delta") or {}
|
||||
if delta.get("reasoning_content"):
|
||||
reasoning_text += delta["reasoning_content"]
|
||||
if rc := delta.get("reasoning_content") or delta.get("reasoning", ""):
|
||||
reasoning_text += rc; yield rc
|
||||
if delta.get("content"):
|
||||
text = delta["content"]; content_text += text; yield text
|
||||
for tc in (delta.get("tool_calls") or []):
|
||||
@@ -331,7 +334,7 @@ def _parse_openai_json(data, api_mode="chat_completions"):
|
||||
else:
|
||||
_record_usage(data.get("usage") or {}, api_mode)
|
||||
msg = (data.get("choices") or [{}])[0].get("message", {})
|
||||
reasoning = msg.get("reasoning_content", "")
|
||||
reasoning = msg.get("reasoning_content") or msg.get("reasoning", "")
|
||||
if reasoning:
|
||||
blocks.append({"type": "thinking", "thinking": reasoning})
|
||||
content = msg.get("content", "")
|
||||
@@ -379,12 +382,13 @@ def _stream_with_retry(sess, url, headers, payload, parse_fn):
|
||||
yield err; return [{"type": "text", "text": err}]
|
||||
gen = parse_fn(r)
|
||||
try:
|
||||
while True: streamed = True; yield next(gen)
|
||||
while True: chunk = next(gen); streamed = True; yield chunk
|
||||
except StopIteration as e:
|
||||
if not e.value and not streamed: raise requests.ConnectionError("empty response")
|
||||
return e.value or []
|
||||
except (requests.Timeout, requests.ConnectionError) as e:
|
||||
err = f"!!!Error: {type(e).__name__}"
|
||||
#pathlib.Path(__file__).parent.joinpath('temp','bad_requests.json').write_text(json.dumps({"url":url,"headers":headers,"payload":payload,"err":str(e),"t":time.time()},ensure_ascii=False),encoding='utf-8')
|
||||
err = f"!!!Error: {type(e).__name__}: {e}" if str(e) else f"!!!Error: {type(e).__name__}"
|
||||
if attempt < sess.max_retries:
|
||||
d = _delay(None, attempt)
|
||||
print(f"[LLM Retry] {type(e).__name__}, retry in {d:.1f}s ({attempt+1}/{sess.max_retries+1})")
|
||||
@@ -400,11 +404,14 @@ def _openai_stream(sess, messages):
|
||||
temperature = sess.temperature
|
||||
if 'kimi' in ml or 'moonshot' in ml: temperature = 1
|
||||
elif 'minimax' in ml: temperature = max(0.01, min(temperature, 1.0)) # MiniMax requires temp in (0, 1]
|
||||
headers = {"Authorization": f"Bearer {sess.api_key}", "Content-Type": "application/json", "Accept": "text/event-stream"}
|
||||
headers = {"Authorization": f"Bearer {sess.api_key}", "Content-Type": "application/json", "Accept": "text/event-stream", 'originator': 'codex_exec'}
|
||||
headers["User-Agent"] = sess.user_agent
|
||||
if api_mode == "responses":
|
||||
url = auto_make_url(sess.api_base, "responses")
|
||||
payload = {"model": model, "input": _to_responses_input(messages), "stream": sess.stream,
|
||||
"prompt_cache_key": _RESP_CACHE_KEY, "instructions": sess.system or "You are an Omnipotent Executor."}
|
||||
"prompt_cache_key": _RESP_CACHE_KEY, "instructions": sess.system or "You are an Omnipotent Executor.",
|
||||
"client_metadata": {"x-codex-window-id": f"{_RESP_CACHE_KEY}:0","x-codex-installation-id": _RESP_CODEX_KEY},
|
||||
'include': ['reasoning.encrypted_content']}
|
||||
if sess.reasoning_effort: payload["reasoning"] = {"effort": sess.reasoning_effort}
|
||||
if sess.max_tokens: payload["max_output_tokens"] = sess.max_tokens
|
||||
else:
|
||||
@@ -531,7 +538,7 @@ class BaseSession:
|
||||
self.max_retries = max(0, int(cfg.get('max_retries', 4)))
|
||||
self.verify = cfg.get('verify', True)
|
||||
self.stream = cfg.get('stream', True)
|
||||
default_ct, default_rt = (5, 30) if self.stream else (10, 240)
|
||||
default_ct, default_rt = (5, 40) if self.stream else (10, 240)
|
||||
self.connect_timeout = max(1, int(cfg.get('timeout', default_ct)))
|
||||
self.read_timeout = max(5, int(cfg.get('read_timeout', default_rt)))
|
||||
def _enum(key, valid):
|
||||
@@ -545,6 +552,8 @@ class BaseSession:
|
||||
self.api_mode = 'responses' if mode in ('responses', 'response') else 'chat_completions'
|
||||
self.temperature = cfg.get('temperature', 1)
|
||||
self.max_tokens = cfg.get('max_tokens')
|
||||
self.default_ua = "claude-cli/2.1.152 (external, cli)"
|
||||
self.user_agent = cfg.get("user_agent", self.default_ua)
|
||||
def _apply_claude_thinking(self, payload):
|
||||
if self.thinking_type:
|
||||
thinking = {"type": self.thinking_type}
|
||||
@@ -654,14 +663,15 @@ def _fix_messages(messages):
|
||||
return merged
|
||||
|
||||
class NativeClaudeSession(BaseSession):
|
||||
native_ua = "claude-cli/2.1.152 (native, cli)"
|
||||
def __init__(self, cfg):
|
||||
super().__init__(cfg)
|
||||
self.fake_cc_system_prompt = cfg.get("fake_cc_system_prompt", False)
|
||||
self.user_agent = cfg.get("user_agent", "claude-cli/2.1.152 (external, cli)")
|
||||
self._session_id = str(uuid.uuid4())
|
||||
self._account_uuid = str(uuid.uuid4())
|
||||
self._device_id = uuid.uuid4().hex + uuid.uuid4().hex[:32]
|
||||
self.tools = None
|
||||
if self.user_agent == self.default_ua: self.user_agent = self.native_ua
|
||||
def raw_ask(self, messages):
|
||||
if self.max_tokens is None: self.max_tokens = 8192
|
||||
model = self.model
|
||||
@@ -678,7 +688,7 @@ class NativeClaudeSession(BaseSession):
|
||||
if self.api_key.startswith("sk-ant-"): headers["x-api-key"] = self.api_key
|
||||
else: headers["authorization"] = f"Bearer {self.api_key}"
|
||||
payload = {"model": model, "messages": messages, "max_tokens": self.max_tokens, "stream": self.stream}
|
||||
if self.fake_cc_system_prompt: payload["max_tokens"] = 64000
|
||||
#if self.fake_cc_system_prompt: payload["max_tokens"] = 64000
|
||||
if self.temperature != 1: payload["temperature"] = self.temperature
|
||||
self._apply_claude_thinking(payload)
|
||||
payload["context_management"] = {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]};
|
||||
@@ -732,6 +742,7 @@ class NativeClaudeSession(BaseSession):
|
||||
return MockResponse(thinking, content, tool_calls, str(content_blocks))
|
||||
|
||||
class NativeOAISession(NativeClaudeSession):
|
||||
native_ua = "codex_exec/0.139.0 (Windows 10.0.26200; x86_64) unknown (codex_exec; 0.139.0)"
|
||||
def raw_ask(self, messages):
|
||||
messages = _fix_messages(messages)
|
||||
messages = _ensure_thinking_blocks(messages, self.model)
|
||||
@@ -789,7 +800,7 @@ class ToolClient:
|
||||
raw_text = ''
|
||||
for chunk in gen:
|
||||
raw_text += chunk; yield chunk
|
||||
_write_llm_log('Response', raw_text, self.log_path)
|
||||
_write_llm_log('Response', raw_text, self.log_path, model=self.backend.model)
|
||||
return self._parse_mixed_response(raw_text)
|
||||
|
||||
def _prepare_tool_instruction(self, tools):
|
||||
@@ -906,13 +917,14 @@ def _ensure_text_block(blocks):
|
||||
blocks.insert(1, {"type": "text", "text": txt})
|
||||
return txt
|
||||
|
||||
def _write_llm_log(label, content, log_path=None):
|
||||
def _write_llm_log(label, content, log_path=None, model=''):
|
||||
if not log_path:
|
||||
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), f'temp/model_responses/model_responses_{os.getpid()}.txt')
|
||||
os.makedirs(os.path.dirname(os.path.abspath(log_path)), exist_ok=True)
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
if model: model = f' model={model}'
|
||||
with open(log_path, 'a', encoding='utf-8', errors='replace') as f:
|
||||
f.write(f"=== {label} === {ts}\n{content}\n\n")
|
||||
f.write(f"=== {label} === {ts}{model}\n{content}\n\n")
|
||||
|
||||
def tryparse(json_str):
|
||||
try: return json.loads(json_str)
|
||||
@@ -1044,7 +1056,7 @@ class NativeToolClient:
|
||||
while True:
|
||||
chunk = next(gen); yield chunk
|
||||
except StopIteration as e: resp = e.value
|
||||
if resp: _write_llm_log('Response', resp.raw, self.log_path)
|
||||
if resp: _write_llm_log('Response', resp.raw, self.log_path, model=self.backend.model)
|
||||
if resp and hasattr(resp, 'tool_calls') and resp.tool_calls: self._pending_tool_ids = [tc.id for tc in resp.tool_calls]
|
||||
return resp
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# adb_ui.py - 一键dump+解析Android UI (u2优先,原生fallback)
|
||||
# 要先看 computer_use.md;dump配合ui_detect,微信/支付宝小程序常需detect补盲
|
||||
# 搜索框一般直接输入拼音/首字母即可,别硬啃 adb 中文输入
|
||||
# u2 (uiautomator2) 不受idle限制,适合动画密集app(美团等)
|
||||
# 弹窗检测: ui(clickable_only=True, raw=True) 找全屏FrameLayout+底部小ImageView(关闭X)
|
||||
# 已知包名: 美团外卖=com.sankuai.meituan.takeoutnew 淘宝=com.taobao.taobao
|
||||
|
||||
+24
-5
@@ -1,12 +1,16 @@
|
||||
# computer_use
|
||||
|
||||
L3 memory: ljqCtrl.py ljqCtrl_sop.md ui_detect.py
|
||||
相关L3 memory: **ui_detect.py** ljqCtrl.py ljqCtrl_sop.md
|
||||
|
||||
## 1. 基础规则
|
||||
枚举窗口总是可用:GUI任务先用 win32gui 枚举标题/类名/rect,确定目标窗口、前台状态和客户区原点后再操作。
|
||||
优先使用 Python UIA;若UIA可用,探测和操作均可使用UIA;游戏不使用UIA;
|
||||
一旦 UIA 对该窗口无效,立刻改用 ui_detect 定位;
|
||||
ui_detect 不足时才用 vision,vision 只用于语义理解、确认界面状态和辅助判断目标,不可信其坐标,不要直接按 vision 坐标点击。
|
||||
### 探测/定位四工具(按优先级降级,前者无效才用后者)
|
||||
| 工具 | 用法时机 | 能力/适用 | 限制 |
|
||||
|------|----------|-----------|------|
|
||||
| 0 win32gui 窗口枚举 | 始终先行,总是可用 | 枚举标题/类名/rect,确定目标窗口、前台状态、客户区原点 | 仅定位窗口,不进控件 |
|
||||
| 1 Python UIA(控件树)| 首选探测+操作 | 控件树可用时,探测与操作(含免坐标点击)都用 UIA | **游戏禁用**;对该窗口一旦无效则后续也不用 |
|
||||
| 2 ui_detect.py(配合ljqCtrl截图) | 1无效时才用 | 截图视觉检测控件,返回 bbox+OCR 文本 | bbox 是截图内坐标需转屏幕物理坐标 |
|
||||
| 3 vision(VLM) | 2仍不足时才用 | 仅语义理解、确认界面状态、辅助判断目标 | 不可信其坐标 |
|
||||
|
||||
Windows 下窗口截图和操作使用 ljqCtrl:严禁 pyautogui;记得先 Activate 到前台;
|
||||
坑1-DPI:一律物理坐标;坑2-遮盖/失焦:混乱先枚举窗口确认前台;
|
||||
ui_detect 的 bbox 是截图内坐标,点击前必须用 `ClientToScreen(hwnd,(0,0))/dpi_scale + bbox中心` 转屏幕物理坐标
|
||||
@@ -20,3 +24,18 @@ ljqCtrl 失效或目标为网络游戏时,必须使用硬件键鼠 Xbananakb /
|
||||
明确一个操作后,可以在同一轮执行该动作,短暂等待,再立刻枚举窗口 + 截图/ui_detect 验证新状态;不要在未知状态下把多步决策写进大脚本
|
||||
尽量不要预测关键词筛候选,应看 detect 输出、坐标、层级和上下文判断
|
||||
若确定UIA可用则少用ui_detect/ljqCtrl;若UIA不可用,则后续不用UIA
|
||||
|
||||
临时截图/可视化文件用后清理,或固定文件名覆盖,避免堆积。
|
||||
ui_detect 可跨端复用;手机端沿用本原则时,UIA 换成 ui dump/adb_ui,ljqCtrl 控制换成 adb
|
||||
|
||||
## 3. macOS 平台
|
||||
macOS 定位链与 §1 一致,工具映射如下:
|
||||
- 控制层:`import macljqCtrl as ljqCtrl`(替代 Windows ljqCtrl)
|
||||
- 窗口枚举:`ListWindows()` → 返回 id/app/title/bbox/pid(替代 win32gui)
|
||||
- 激活:`ActivateApp(pid)`(pid 来自 ListWindows,禁止用名字子串——同厂商 bundle 前缀会误伤)
|
||||
- UIA 层 = AX 辅助功能 API:`AXElements(pid)` 枚举控件树 → role/desc/title/id/物理坐标 xywh;`AXFind(pid, role=, desc=, title=)` 过滤;`AXPress(el)` 免坐标点击
|
||||
- 坐标:AX 返回逻辑点,库内自动 /dpi_scale 转物理像素,与 Click/Screenshot 统一;Retina 默认 scale=0.5
|
||||
- 截图:`GrabWindow(window_id)` 或 `ScreenCapAt(x, y, radius)`(物理坐标)
|
||||
- 权限:首次使用需授予「辅助功能」权限(系统设置 > 隐私与安全 > 辅助功能);`AXIsProcessTrusted()` 检测
|
||||
- 依赖:`pip install pyobjc-framework-ApplicationServices`(AX 相关,软依赖——未装时键鼠/截图正常,仅 AX 函数不可用)
|
||||
- 节奏同 §2:先 ListWindows + AXElements 探测,确认控件后再操作;AXPress 优先,回退 Click(phys_cx, phys_cy)
|
||||
+11
-1
@@ -6,13 +6,23 @@
|
||||
GARoot/*.py
|
||||
GARoot/assets/*.txt *.json
|
||||
mykey.py
|
||||
GARoot/memory/ # 默认gitignore白名单的SOP,另外可以直接看情况修改干预
|
||||
GARoot/memory/
|
||||
```
|
||||
memory下大部分文件不适合分发,不要复制 memory 下未被 gitignore 白名单的文件!
|
||||
不要复制 memory 下的L1/L2文件(global_mem(_insight).txt),会自动初始化
|
||||
|
||||
打包红线:严格按上面4行清单执行,`*` 是 glob 全匹配,不得擅自改成“必要文件/可启动闭包”。
|
||||
- `GARoot/*.py` 必须包含根目录所有 `.py`。
|
||||
- `GARoot/assets/*.txt *.json` 必须包含 assets 顶层所有 `.txt`/`.json`。
|
||||
- `GARoot/memory/` 只取 `.gitignore` 白名单/已允许分发文件;排除 `global_mem.txt`、`global_mem_insight.txt`、`__pycache__/`、`*.pyc`。
|
||||
- 按当前清单实测压缩包约153KB/55文件;正常不应超过170KB,文件数不应超过60。
|
||||
|
||||
## 依赖
|
||||
requests beautifulsoup4
|
||||
尽量复用远端已有python/venv
|
||||
|
||||
## 通信
|
||||
看subagent.md
|
||||
同subagent协议:`agentmain.py --task {name} --input "..."`
|
||||
或起reflect worker并设置bbs信息
|
||||
|
||||
|
||||
+5
-3
@@ -21,6 +21,8 @@ try:
|
||||
import cv2
|
||||
except: pass
|
||||
|
||||
ctypes.windll.user32.SetProcessDPIAware()
|
||||
|
||||
_hdc = ctypes.windll.user32.GetDC(0)
|
||||
swidth = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 118) # DESKTOPHORZRES (物理)
|
||||
sheight = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 117) # DESKTOPVERTRES
|
||||
@@ -158,9 +160,9 @@ def FindBlock(fn, wrect=None, verbose=0, threshold=0.8):
|
||||
obj = (oj + wrect[0] + tsw//2, oi + wrect[1] + tsh//2)
|
||||
if verbose:
|
||||
print(f'Max match: {max_val:.4f} at ({oj}, {oi}) cost: {time.process_time() - tic:.3f}s')
|
||||
sscr = scr.crop([oj, oi, oj+tsw, oi+tsh])
|
||||
sscr.show()
|
||||
return obj, max_val > threshold
|
||||
#sscr = scr.crop([oj, oi, oj+tsw, oi+tsh])
|
||||
#sscr.show()
|
||||
return obj, max_val
|
||||
|
||||
def ScreenCapAt(x, y, r=100):
|
||||
"""物理坐标(x,y)为中心±r的屏幕截图 → PIL Image"""
|
||||
|
||||
+15
-2
@@ -10,10 +10,13 @@
|
||||
- `ljqCtrl.GrabWindow(hwnd_or_name)`: 前台截图(先Activate), 传hwnd(int)或窗口标题子串(str), 返回PIL Image
|
||||
- `ljqCtrl.GrabWindowBg(hwnd_or_name, timeout=5)`: WGC后台截图(Win10+)
|
||||
- `ljqCtrl.MouseDClick(staytime=0.05)`: 鼠标双击
|
||||
- 可先阅读computer_use.md
|
||||
|
||||
## 1. 环境载入
|
||||
import ljqCtrl
|
||||
|
||||
> **macOS**: 改 `import macljqCtrl as ljqCtrl`(API 镜像,Quartz/screencapture 实现)。依赖 `pyobjc-framework-Quartz`/`-Cocoa`,首次用前 `macljqCtrl.check_permissions()` 自检辅助功能/录屏授权。
|
||||
|
||||
## 2. 核心:High-DPI 物理坐标换算
|
||||
`ljqCtrl` 的 `Click/MoveTo` 接口接收的是**物理像素坐标**。
|
||||
当使用 `pygetwindow` 等其他工具获取窗口位置(逻辑坐标)时,必须除以缩放系数。
|
||||
@@ -30,11 +33,21 @@ ljqCtrl.Click(ox + (bbox[0]+bbox[2])//2, oy + (bbox[1]+bbox[3])//2)
|
||||
```
|
||||
禁止全屏ImageGrab(必须针对窗口),所有逻辑坐标都要转物理。
|
||||
|
||||
**macOS (`macljqCtrl`)**:`GrabScreen(bbox)` 区域截图后,图内点转屏幕物理坐标用 `CropToScreen(bbox, px, py)`,别手搓 `screencapture -R`(它吃逻辑点,会点歪)。
|
||||
|
||||
## 4. 避坑指南
|
||||
- **⚠️ 一律使用物理坐标**:传给 ljqCtrl.Click/SetCursorPos 的坐标必须是物理坐标(=截图像素坐标)。禁止传入逻辑坐标。
|
||||
- **物理验证**:模拟操作前必须确保窗口已通过 `activate()` 置于前台。
|
||||
- **坐标对齐**: 物理坐标 = 截图坐标;ljqCtrl 自动处理 DPI 换算,禁止手动重复计算。
|
||||
- **⚠️ 窗口坐标转换陷阱**:使用 `win32gui.GetWindowRect(hwnd)` 获取的矩形包含标题栏和边框,而截图内容是客户区。点击截图内元素时,必须用 `win32gui.ClientToScreen(hwnd, (0, 0))` 获取客户区原点的屏幕坐标,再加上截图内坐标。禁止直接用 GetWindowRect 左上角 + 截图坐标。**同理禁止 `DwmGetWindowAttribute(hwnd, 9, ...)` 取窗口矩形替代 ClientToScreen,它也包含标题栏/阴影。**
|
||||
- **⚠️ Click 后 0% 像素变化 = 点歪了**:ljqCtrl.Click 会报告像素变化百分比。若为 0% 或接近 0%,说明点击落在了错误位置(坐标计算有误),必须立即停下来诊断坐标转换逻辑,禁止盲目重试。常见原因:用了错误的窗口原点API、忘记 `/dpi_scale`、混淆了客户区与窗口矩形。
|
||||
- **⚠️ Click 后 0% 像素变化 = 点歪了**:ljqCtrl.Click 会报告像素变化百分比。若为 0% 或接近 0%,说明点击落在了错误位置(坐标计算有误),必须立即停下来诊断坐标转换逻辑,禁止盲目重试。常见原因:用了错误的窗口原点API、忘记 `/dpi_scale`、混淆了客户区与窗口矩形。macOS 上多为忘加裁剪原点(应走 `CropToScreen`)。
|
||||
- **⚠️ win32 DPI 坐标陷阱**:未调用 `SetProcessDPIAware()` 时,`GetWindowRect/ClientToScreen/GetClientRect` 等拿到的窗口/客户区坐标通常是**逻辑坐标**,必须进行换算!
|
||||
- **文本输入**:ljqCtrl 无 TypeText/SendKeys。向输入框键入文本:先点击/三击选中字段,再 `pyperclip.copy('文本'); ljqCtrl.Press('ctrl+v')`。
|
||||
- **文本输入**:ljqCtrl 无 TypeText/SendKeys。向输入框键入文本:先点击/三击选中字段,再 `pyperclip.copy('文本'); ljqCtrl.Press('ctrl+v')`。
|
||||
|
||||
## 5. macOS:OCR/vision 认不准图标时,用辅助功能 API 枚举真实控件(强烈推荐)
|
||||
> **两条通路**:①`macljqCtrl.py` 已封装原生 pyobjc AX API(首选,免 shell):`AXElements(pid或bundle_id或app名)` 枚举控件树(带 role/desc/title/id/value/**enabled**/物理坐标),`AXFind(...,enabled_only=)` 过滤,`AXClick(node)` = AXPress 优先失败回退物理坐标 Click。②无 pyobjc 时回退下述 osascript 方案。
|
||||
图标类按钮(···更多 / 铅笔编辑 / 关闭等)靠 OCR/vision 极易误判误点。优先走 GUI 优先链的「UIA」层:用 `osascript` 的 System Events 递归 `entire contents` 枚举进程**所有窗口**的真实控件,拿到 `AXRole + description(标识符) + position`,直接 `perform action "AXPress"` 点中。
|
||||
- **关键坑**:弹窗/详情卡常是**独立子窗口**,`front window` 只返回主窗(如红绿灯按钮)。必须 `every window` 遍历 + `entire contents`,否则找不到目标控件。
|
||||
- 控件常自带语义化 `description`/`identifier`(如 `xxx_button_more`),按 description 精确匹配比坐标稳定,枚举一次记下目标标识即可复用。
|
||||
- **坐标换算**:AX 返回的是**逻辑坐标**,截图/Click 用**物理坐标**,retina 屏 ×2(逻辑(537,121)↔物理(1074,242)实测吻合)。AX `AXPress` 直接作用元素免换算;若 AX 偶发 NOTFOUND(时序波动),用换算后物理坐标 `Click` 兜底。
|
||||
- **失焦陷阱**:点击坐标若落在窗口边界外,会点到背后别的 app 导致目标失焦。osascript `tell application "<App>" to activate` 比 ljqCtrl 的 ActivateApp 更可靠,激活后用 `frontmost` 确认。
|
||||
@@ -0,0 +1,590 @@
|
||||
"""
|
||||
macljqCtrl —— ljqCtrl 的 macOS 等价实现 (Quartz CGEvent + screencapture)。
|
||||
与 ljqCtrl.py API 镜像对齐, 跨平台代码可写 `import macljqCtrl as ljqCtrl`。
|
||||
|
||||
CRITICAL: 严禁 import pyautogui。
|
||||
依赖: pyobjc-framework-Quartz, pyobjc-framework-Cocoa (其余 numpy/opencv/Pillow 同 Windows 版)。
|
||||
坐标约定 (与 Windows 版完全一致):
|
||||
- 对外 API 接收【物理像素坐标】(= screencapture/ui_detect 截图内坐标)
|
||||
- dpi_scale = 逻辑点 / 物理像素 (Retina=0.5, 普通屏=1.0)
|
||||
- 逻辑坐标 = 物理坐标 * dpi_scale (CGEvent 内部用逻辑点)
|
||||
|
||||
权限前置 (缺失则键鼠/截图静默失败):
|
||||
- 辅助功能(Accessibility): 系统设置>隐私与安全性>辅助功能, 授权 GA 宿主进程
|
||||
- 屏幕录制(Screen Recording): 同上>屏幕录制
|
||||
用 macljqCtrl.check_permissions() 自检。
|
||||
|
||||
API 快速参考:
|
||||
- dpi_scale: float
|
||||
- Click(x, y=None, check=True): 物理坐标; check=True 比较前后像素变化, 返回变化信息
|
||||
- SetCursorPos((x,y)): 物理坐标移动鼠标
|
||||
- Press(cmd, staytime=0): 快捷键, 如 'cmd+v' 'cmd+c' 'enter' 'cmd+shift+4'
|
||||
- TypeText(s): 直接键入文本(Unicode, 无需剪贴板)
|
||||
- MouseClick / MouseDClick / MouseDown / MouseUp / RightClick
|
||||
- GrabWindow(win) -> PIL Image: 指定窗口截图(物理像素)。win=窗口号(int)或标题/应用名子串(str)
|
||||
- GrabScreen(bbox=None) -> PIL Image: 全屏或区域截图, bbox=(l,u,r,b)物理像素
|
||||
- ScreenCapAt(x, y, r=100) -> PIL Image: 物理坐标(x,y)±r 区域截图
|
||||
- FindBlock(fn, wrect=None, threshold=0.8) -> ((cx,cy), is_found): 模板匹配, 物理坐标
|
||||
- ListWindows(name=None) -> [dict]: 枚举窗口(替代 win32gui), 返回号/标题/应用/物理bbox
|
||||
- ActivateApp(name): 激活应用到前台(替代 win32gui.SetForegroundWindow)
|
||||
"""
|
||||
import os, sys, time, subprocess, tempfile, math
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageGrab, ImageEnhance, ImageFilter, ImageDraw
|
||||
import cv2
|
||||
_HAS_CV2 = True
|
||||
except Exception:
|
||||
_HAS_CV2 = False
|
||||
|
||||
import Quartz
|
||||
from AppKit import NSScreen, NSWorkspace, NSPasteboard, NSStringPboardType, NSRunningApplication
|
||||
|
||||
verbose_click = True # Click 像素变化打印开关
|
||||
|
||||
# ---------- 屏幕几何 & dpi_scale ----------
|
||||
_main = Quartz.CGMainDisplayID()
|
||||
_bounds = Quartz.CGDisplayBounds(_main)
|
||||
cwidth = int(_bounds.size.width) # 逻辑点宽 (CGEvent 坐标系)
|
||||
cheight = int(_bounds.size.height)
|
||||
_mode = Quartz.CGDisplayCopyDisplayMode(_main)
|
||||
swidth = int(Quartz.CGDisplayModeGetPixelWidth(_mode)) # 物理像素宽
|
||||
sheight = int(Quartz.CGDisplayModeGetPixelHeight(_mode))
|
||||
dpi_scale = cwidth / swidth if swidth else 1.0 # 逻辑/物理, Retina=0.5
|
||||
|
||||
|
||||
def check_permissions(verbose=True):
|
||||
"""返回 (accessibility_ok, screen_recording_ok)。缺失时打印授权指引。"""
|
||||
sc = bool(Quartz.CGPreflightScreenCaptureAccess()) if hasattr(Quartz, 'CGPreflightScreenCaptureAccess') else None
|
||||
ax = None
|
||||
try:
|
||||
import HIServices
|
||||
ax = bool(HIServices.AXIsProcessTrusted())
|
||||
except Exception:
|
||||
try:
|
||||
from ApplicationServices import AXIsProcessTrusted
|
||||
ax = bool(AXIsProcessTrusted())
|
||||
except Exception:
|
||||
ax = None
|
||||
if verbose:
|
||||
print(f'[PERM] Accessibility(键鼠): {ax} ScreenRecording(截图): {sc}')
|
||||
if ax is False:
|
||||
print(' → 系统设置>隐私与安全性>辅助功能, 勾选 GA 宿主进程后重启 GA')
|
||||
if sc is False:
|
||||
print(' → 系统设置>隐私与安全性>屏幕录制, 勾选 GA 宿主进程后重启 GA')
|
||||
return ax, sc
|
||||
|
||||
|
||||
# ---------- 鼠标 ----------
|
||||
def _post(ev):
|
||||
Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev)
|
||||
|
||||
def _cursor_logical():
|
||||
e = Quartz.CGEventCreate(None)
|
||||
loc = Quartz.CGEventGetLocation(e)
|
||||
return loc.x, loc.y
|
||||
|
||||
def _phys_to_logical(x, y):
|
||||
return x * dpi_scale, y * dpi_scale
|
||||
|
||||
def SetCursorPos(z):
|
||||
"""z=(x,y) 物理坐标。移动鼠标(不点击)。"""
|
||||
lx, ly = _phys_to_logical(int(z[0]), int(z[1]))
|
||||
ev = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved,
|
||||
Quartz.CGPointMake(lx, ly), Quartz.kCGMouseButtonLeft)
|
||||
_post(ev)
|
||||
time.sleep(0.05)
|
||||
|
||||
def _mouse_event(etype, button=Quartz.kCGMouseButtonLeft):
|
||||
lx, ly = _cursor_logical()
|
||||
ev = Quartz.CGEventCreateMouseEvent(None, etype, Quartz.CGPointMake(lx, ly), button)
|
||||
_post(ev)
|
||||
|
||||
def MouseDown():
|
||||
_mouse_event(Quartz.kCGEventLeftMouseDown)
|
||||
def MouseUp():
|
||||
_mouse_event(Quartz.kCGEventLeftMouseUp)
|
||||
|
||||
def MouseClick(staytime=0.05):
|
||||
MouseDown(); time.sleep(staytime)
|
||||
MouseUp(); time.sleep(0.05)
|
||||
|
||||
def MouseDClick(staytime=0.05):
|
||||
# 真双击: 同坐标连发2次, 用 click state=2
|
||||
lx, ly = _cursor_logical()
|
||||
p = Quartz.CGPointMake(lx, ly)
|
||||
for state in (1, 2):
|
||||
down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, p, Quartz.kCGMouseButtonLeft)
|
||||
Quartz.CGEventSetIntegerValueField(down, Quartz.kCGMouseEventClickState, state)
|
||||
_post(down)
|
||||
up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, p, Quartz.kCGMouseButtonLeft)
|
||||
Quartz.CGEventSetIntegerValueField(up, Quartz.kCGMouseEventClickState, state)
|
||||
_post(up)
|
||||
time.sleep(0.05)
|
||||
|
||||
def RightClick(staytime=0.05):
|
||||
lx, ly = _cursor_logical()
|
||||
p = Quartz.CGPointMake(lx, ly)
|
||||
down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseDown, p, Quartz.kCGMouseButtonRight)
|
||||
_post(down); time.sleep(staytime)
|
||||
up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseUp, p, Quartz.kCGMouseButtonRight)
|
||||
_post(up); time.sleep(0.05)
|
||||
|
||||
# ---------- 键盘 ----------
|
||||
# macOS 虚拟键码 (kVK_*)
|
||||
_KEYMAP = {
|
||||
'a':0,'s':1,'d':2,'f':3,'h':4,'g':5,'z':6,'x':7,'c':8,'v':9,'b':11,'q':12,
|
||||
'w':13,'e':14,'r':15,'y':16,'t':17,'1':18,'2':19,'3':20,'4':21,'6':22,'5':23,
|
||||
'=':24,'9':25,'7':26,'-':27,'8':28,'0':29,']':30,'o':31,'u':32,'[':33,'i':34,
|
||||
'p':35,'l':37,'j':38,"'":39,'k':40,';':41,'\\':42,',':43,'/':44,'n':45,'m':46,
|
||||
'.':47,'`':50,
|
||||
'enter':36,'return':36,'tab':48,'space':49,' ':49,'delete':51,'backspace':51,
|
||||
'esc':53,'escape':53,'forwarddelete':117,
|
||||
'left':123,'right':124,'down':125,'up':126,
|
||||
'home':115,'end':119,'pageup':116,'pagedown':121,
|
||||
'f1':122,'f2':120,'f3':99,'f4':118,'f5':96,'f6':97,'f7':98,'f8':100,'f9':101,
|
||||
'f10':109,'f11':103,'f12':111,
|
||||
}
|
||||
_MODS = {
|
||||
'cmd':Quartz.kCGEventFlagMaskCommand, 'command':Quartz.kCGEventFlagMaskCommand,
|
||||
'ctrl':Quartz.kCGEventFlagMaskControl, 'control':Quartz.kCGEventFlagMaskControl,
|
||||
'alt':Quartz.kCGEventFlagMaskAlternate, 'option':Quartz.kCGEventFlagMaskAlternate,
|
||||
'opt':Quartz.kCGEventFlagMaskAlternate,
|
||||
'shift':Quartz.kCGEventFlagMaskShift,
|
||||
'fn':Quartz.kCGEventFlagMaskSecondaryFn,
|
||||
}
|
||||
|
||||
def _key_tap(keycode, flags=0):
|
||||
down = Quartz.CGEventCreateKeyboardEvent(None, keycode, True)
|
||||
if flags: Quartz.CGEventSetFlags(down, flags)
|
||||
_post(down)
|
||||
up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False)
|
||||
if flags: Quartz.CGEventSetFlags(up, flags)
|
||||
_post(up)
|
||||
|
||||
def Press(cmd, staytime=0):
|
||||
"""快捷键。如 'cmd+v' 'cmd+shift+4' 'enter' 'cmd+c'。Win版的 ctrl 在mac多对应 cmd, 调用方自行决定。"""
|
||||
parts = [p.strip().lower() for p in str(cmd).split('+') if p.strip()]
|
||||
flags = 0; key = None
|
||||
for p in parts:
|
||||
if p in _MODS: flags |= _MODS[p]
|
||||
else: key = p
|
||||
if key is None:
|
||||
return
|
||||
kc = _KEYMAP.get(key)
|
||||
if kc is None:
|
||||
# 单字符走 TypeText
|
||||
TypeText(key)
|
||||
else:
|
||||
_key_tap(kc, flags)
|
||||
if staytime: time.sleep(staytime)
|
||||
time.sleep(0.03)
|
||||
|
||||
def TypeText(s):
|
||||
"""直接键入 Unicode 文本 (无需剪贴板)。"""
|
||||
for ch in str(s):
|
||||
ev = Quartz.CGEventCreateKeyboardEvent(None, 0, True)
|
||||
Quartz.CGEventKeyboardSetUnicodeString(ev, len(ch), ch)
|
||||
_post(ev)
|
||||
ev2 = Quartz.CGEventCreateKeyboardEvent(None, 0, False)
|
||||
Quartz.CGEventKeyboardSetUnicodeString(ev2, len(ch), ch)
|
||||
_post(ev2)
|
||||
time.sleep(0.005)
|
||||
|
||||
# 剪贴板 (替代 pyperclip)
|
||||
def set_clipboard(text):
|
||||
pb = NSPasteboard.generalPasteboard()
|
||||
pb.clearContents()
|
||||
pb.setString_forType_(text, NSStringPboardType)
|
||||
|
||||
def get_clipboard():
|
||||
pb = NSPasteboard.generalPasteboard()
|
||||
return pb.stringForType_(NSStringPboardType)
|
||||
|
||||
def Paste(text):
|
||||
"""把 text 放剪贴板并 cmd+v 粘贴 (等价 Win版 pyperclip+ctrl+v)。"""
|
||||
set_clipboard(text); time.sleep(0.05); Press('cmd+v')
|
||||
|
||||
|
||||
# ---------- 截图 (screencapture, 输出物理像素) ----------
|
||||
def GrabScreen(bbox=None):
|
||||
"""全屏或区域截图 -> PIL Image。bbox=(l,u,r,b) 物理像素坐标。
|
||||
传 bbox 后图内坐标相对裁剪原点; 转屏幕绝对坐标用 CropToScreen, 勿手搓 screencapture -R。
|
||||
"""
|
||||
fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd)
|
||||
try:
|
||||
if bbox:
|
||||
l, u, r, b = bbox
|
||||
# screencapture -R 用逻辑点; 转换 物理->逻辑
|
||||
lx, ly = l*dpi_scale, u*dpi_scale
|
||||
lw, lh = (r-l)*dpi_scale, (b-u)*dpi_scale
|
||||
cmd = ['/usr/sbin/screencapture','-x','-t','png',
|
||||
f'-R{lx:.0f},{ly:.0f},{lw:.0f},{lh:.0f}', fn]
|
||||
else:
|
||||
cmd = ['/usr/sbin/screencapture','-x','-t','png', fn]
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
return Image.open(fn).copy()
|
||||
finally:
|
||||
try: os.remove(fn)
|
||||
except Exception: pass
|
||||
|
||||
def ScreenCapAt(x, y, r=100):
|
||||
"""物理坐标(x,y)为中心±r 截图 -> PIL Image。"""
|
||||
return GrabScreen((x-r, y-r, x+r, y+r))
|
||||
|
||||
def CropToScreen(bbox, x, y=None):
|
||||
"""裁剪图内坐标 -> 屏幕绝对物理坐标。bbox=GrabScreen 用的 (l,u,r,b) 物理像素。
|
||||
(x,y)=在 GrabScreen(bbox) 返回图内找到的点。返回可直接喂给 Click 的 (X,Y)。
|
||||
macOS 版的 ClientToScreen: 纯加裁剪原点偏移, 不做缩放(裁剪图与 bbox 同物理像素)。"""
|
||||
if y is None and isinstance(x, (tuple, list)):
|
||||
x, y = x[0], x[1]
|
||||
return int(bbox[0] + x), int(bbox[1] + y)
|
||||
|
||||
def GrabWindow(win):
|
||||
"""窗口截图 -> PIL Image。win=窗口号(int) 或 标题/应用名子串(str)。物理像素。"""
|
||||
if isinstance(win, str):
|
||||
ws = ListWindows(win)
|
||||
if not ws: raise RuntimeError(f'window not found: {win}')
|
||||
win = ws[0]['id']
|
||||
fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd)
|
||||
try:
|
||||
subprocess.run(['/usr/sbin/screencapture','-x','-o','-l',str(win),'-t','png',fn],
|
||||
check=True, capture_output=True)
|
||||
return Image.open(fn).copy()
|
||||
finally:
|
||||
try: os.remove(fn)
|
||||
except Exception: pass
|
||||
|
||||
# ---------- 窗口枚举 / 激活 (替代 win32gui) ----------
|
||||
def ListWindows(name=None):
|
||||
"""枚举屏上窗口 -> [{'id','title','app','bbox'(物理像素 l,u,r,b),'pid'}]。
|
||||
name: 标题或应用名子串过滤(不区分大小写)。"""
|
||||
opts = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements
|
||||
infos = Quartz.CGWindowListCopyWindowInfo(opts, Quartz.kCGNullWindowID)
|
||||
out = []
|
||||
inv = 1.0 / dpi_scale # 逻辑->物理
|
||||
for w in infos:
|
||||
b = w.get('kCGWindowBounds') or {}
|
||||
layer = w.get('kCGWindowLayer', 0)
|
||||
if layer != 0: # 只要普通应用窗口层
|
||||
continue
|
||||
title = w.get('kCGWindowName') or ''
|
||||
app = w.get('kCGWindowOwnerName') or ''
|
||||
l = b.get('X', 0)*inv; u = b.get('Y', 0)*inv
|
||||
r = l + b.get('Width', 0)*inv; bo = u + b.get('Height', 0)*inv
|
||||
rec = {'id': int(w.get('kCGWindowNumber', 0)), 'title': title, 'app': app,
|
||||
'pid': int(w.get('kCGWindowOwnerPID', 0)),
|
||||
'bbox': (int(l), int(u), int(r), int(bo))}
|
||||
if name:
|
||||
n = name.lower()
|
||||
if n not in title.lower() and n not in app.lower():
|
||||
continue
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
def ActivateApp(target):
|
||||
"""激活应用到前台 (替代 SetForegroundWindow)。
|
||||
|
||||
macOS 的前台是 *应用* 粒度而非窗口句柄粒度, 故无法 1:1 镜像 Win 版
|
||||
Activate(hwnd)。target 支持两种定位键, 优先用精确的:
|
||||
- int : 进程 pid (来自 ListWindows 的 'pid' 字段) —— 精确, 不受语言/本地化影响, **推荐**
|
||||
- str : 应用名 / bundle id。按精确度分级匹配, 避免误中同厂商应用:
|
||||
① bundle id 精确等值 (如 'com.tencent.meeting') —— 最可靠
|
||||
② localizedName 精确等值 (如 '腾讯会议')
|
||||
③ localizedName 子串 (最后兜底, 可能模糊)
|
||||
注意: 不对 bundle id 做子串匹配, 因同厂商应用共享前缀
|
||||
(微信 com.tencent.xinWeChat 与腾讯会议 com.tencent.meeting 都含 'tencent')。
|
||||
返回是否成功。"""
|
||||
ws = NSWorkspace.sharedWorkspace()
|
||||
# 1) pid 精确激活 (首选)
|
||||
if isinstance(target, int):
|
||||
app = NSRunningApplication.runningApplicationWithProcessIdentifier_(target)
|
||||
if app is not None:
|
||||
app.activateWithOptions_(1 << 1) # NSApplicationActivateAllWindows
|
||||
time.sleep(0.3)
|
||||
return True
|
||||
return False
|
||||
# 2) 字符串: 按精确度分级匹配 (避免同厂商前缀误伤)
|
||||
key = str(target)
|
||||
keyl = key.lower()
|
||||
apps = list(ws.runningApplications())
|
||||
def _fire(app):
|
||||
app.activateWithOptions_(1 << 1)
|
||||
time.sleep(0.3)
|
||||
return True
|
||||
# ① bundle id 精确等值
|
||||
for app in apps:
|
||||
if (app.bundleIdentifier() or '').lower() == keyl:
|
||||
return _fire(app)
|
||||
# ② localizedName 精确等值
|
||||
for app in apps:
|
||||
if (app.localizedName() or '').lower() == keyl:
|
||||
return _fire(app)
|
||||
# ③ localizedName 子串 (兜底)
|
||||
for app in apps:
|
||||
if keyl in (app.localizedName() or '').lower():
|
||||
return _fire(app)
|
||||
# 3) 兜底用 open -a
|
||||
try:
|
||||
subprocess.run(['open', '-a', str(target)], check=True, capture_output=True)
|
||||
time.sleep(0.5); return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------- 模板匹配 FindBlock ----------
|
||||
def GetWRect(sr):
|
||||
"""快捷区域名 -> 物理像素 [l,u,r,b]。如 'left2'=左半屏, 'top3'=上1/3。"""
|
||||
num = int(sr[-1])
|
||||
l, u, r, b = 0, 0, swidth, sheight
|
||||
if 'left' in sr: r = swidth // num
|
||||
if 'right' in sr: l = swidth * (num - 1) // num
|
||||
if 'top' in sr: b = sheight // num
|
||||
if 'bottom' in sr: u = sheight * (num - 1) // num
|
||||
return [l, u, r, b]
|
||||
|
||||
def FindBlock(fn, wrect=None, verbose=0, threshold=0.8):
|
||||
"""在屏幕(或wrect区域)内找模板图 fn。返回 ((cx,cy), is_found), 物理坐标。
|
||||
fn: 模板图路径(str)或 PIL Image。
|
||||
wrect: None=全屏 | [l,u,r,b]物理像素 | 'left2'等快捷名 | PIL Image(直接当搜索底图)。"""
|
||||
if not _HAS_CV2:
|
||||
raise RuntimeError('FindBlock 需要 opencv-python 和 Pillow')
|
||||
if wrect is not None and isinstance(wrect, Image.Image):
|
||||
scr, wrect = wrect, None
|
||||
else:
|
||||
if isinstance(wrect, str): wrect = GetWRect(wrect)
|
||||
scr = GrabScreen(wrect) # 物理像素
|
||||
blc = Image.open(fn) if isinstance(fn, str) else fn
|
||||
T = cv2.cvtColor(np.array(blc), cv2.COLOR_RGB2BGR)
|
||||
B = cv2.cvtColor(np.array(scr), cv2.COLOR_RGB2BGR)
|
||||
tsh, tsw = T.shape[:2]
|
||||
res = cv2.matchTemplate(B, T, cv2.TM_CCOEFF_NORMED)
|
||||
_, max_val, _, max_loc = cv2.minMaxLoc(res)
|
||||
oj, oi = max_loc
|
||||
if wrect is None: wrect = [0, 0, scr.size[0], scr.size[1]]
|
||||
obj = (oj + wrect[0] + tsw // 2, oi + wrect[1] + tsh // 2)
|
||||
if verbose:
|
||||
print(f'FindBlock {fn}: score={max_val:.4f} at phys={obj}')
|
||||
return obj, max_val > threshold
|
||||
|
||||
def imshow(mt, sec=0):
|
||||
cv2.imshow('cc', mt); cv2.waitKey(sec)
|
||||
|
||||
|
||||
|
||||
# ---------- Click (带像素变化检测) ----------
|
||||
def Click(x, y=None, check=True, r=60):
|
||||
"""物理坐标点击。check=True 时比较点击前后周边像素变化, 返回 (变化百分比, 截图)。
|
||||
若变化≈0 → 可能点歪了 (同 Win 版语义)。"""
|
||||
if y is None and isinstance(x, (tuple, list)):
|
||||
x, y = x[0], x[1]
|
||||
x, y = int(x), int(y)
|
||||
before = None
|
||||
if check:
|
||||
try: before = np.array(ScreenCapAt(x, y, r))
|
||||
except Exception: before = None
|
||||
SetCursorPos((x, y)); time.sleep(0.05)
|
||||
MouseClick()
|
||||
if check:
|
||||
time.sleep(0.25)
|
||||
try:
|
||||
after = np.array(ScreenCapAt(x, y, r))
|
||||
except Exception:
|
||||
return None
|
||||
if before is not None and before.shape == after.shape:
|
||||
diff = np.mean(np.any(before != after, axis=-1)) * 100
|
||||
if verbose_click:
|
||||
print(f'Click({x},{y}) pixel change: {diff:.1f}%')
|
||||
if diff < 0.5:
|
||||
print(f'[WARN] Click({x},{y}) 像素变化≈0%, 可能点歪了, 请诊断坐标! '
|
||||
'常见错因: 用了裁剪图内坐标却没加裁剪原点(用 CropToScreen), '
|
||||
'或对已是物理像素的坐标又做了 *dpi_scale 换算。')
|
||||
return diff, Image.fromarray(after)
|
||||
return None
|
||||
|
||||
|
||||
# ---------- AX 辅助功能控件枚举 (UIA 层的 macOS 等价) ----------
|
||||
try:
|
||||
from ApplicationServices import (
|
||||
AXUIElementCreateApplication, AXUIElementCopyAttributeValue,
|
||||
AXValueGetValue, AXUIElementPerformAction, AXIsProcessTrusted,
|
||||
kAXChildrenAttribute, kAXRoleAttribute, kAXDescriptionAttribute,
|
||||
kAXPositionAttribute, kAXSizeAttribute, kAXWindowsAttribute,
|
||||
kAXTitleAttribute, kAXValueCGPointType, kAXValueCGSizeType,
|
||||
kAXPressAction, kAXEnabledAttribute,
|
||||
)
|
||||
_HAS_AX = True
|
||||
except ImportError:
|
||||
_HAS_AX = False
|
||||
|
||||
|
||||
def _resolve_pid(target):
|
||||
"""target(int pid | str bundle_id/应用名) → pid(int)。
|
||||
|
||||
str 优先按 bundle id 精确匹配, 再按 localizedName 精确/子串兜底,
|
||||
与 ActivateApp 的匹配纪律一致, 避免同厂商前缀误伤。"""
|
||||
if isinstance(target, int):
|
||||
return int(target)
|
||||
key = str(target); keyl = key.lower()
|
||||
ws = NSWorkspace.sharedWorkspace()
|
||||
apps = list(ws.runningApplications())
|
||||
for a in apps: # ① bundle id 精确
|
||||
if (a.bundleIdentifier() or '') == key:
|
||||
return int(a.processIdentifier())
|
||||
for a in apps: # ② localizedName 精确
|
||||
if (a.localizedName() or '').lower() == keyl:
|
||||
return int(a.processIdentifier())
|
||||
for a in apps: # ③ localizedName 子串
|
||||
if keyl in (a.localizedName() or '').lower():
|
||||
return int(a.processIdentifier())
|
||||
raise ValueError(f'找不到 target={target!r} 对应的运行中应用')
|
||||
|
||||
|
||||
def _ax_attr(el, key):
|
||||
"""读取单个 AX 属性,失败返回 None。"""
|
||||
if not _HAS_AX:
|
||||
return None
|
||||
err, val = AXUIElementCopyAttributeValue(el, key, None)
|
||||
return val if err == 0 else None
|
||||
|
||||
|
||||
def AXElements(target, max_depth=10, include_zero_size=False):
|
||||
"""枚举应用控件树。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
target : int | str
|
||||
pid(int) 或 bundle_id(str, 如 'com.tencent.meeting')。
|
||||
max_depth : int
|
||||
递归深度上限。
|
||||
include_zero_size : bool
|
||||
是否包含 w<=0 或 h<=0 的零尺寸节点。
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict] : 每项含 role/desc/title/id/value/x/y/w/h(物理像素)/depth/el。
|
||||
value 为控件当前值(文本/输入框内容等),非文本值为 None。
|
||||
el 是原始 AXUIElement 引用,目标窗口关闭/重建后失效,AXPress 前宜就近重新枚举。
|
||||
"""
|
||||
if not _HAS_AX:
|
||||
raise RuntimeError(
|
||||
'AX 不可用。请安装: pip install pyobjc-framework-ApplicationServices')
|
||||
if not AXIsProcessTrusted():
|
||||
raise PermissionError('需要授予辅助功能权限(系统设置 > 隐私与安全 > 辅助功能)')
|
||||
pid = _resolve_pid(target)
|
||||
|
||||
app_el = AXUIElementCreateApplication(pid)
|
||||
wins = _ax_attr(app_el, kAXWindowsAttribute) or []
|
||||
scale = dpi_scale # 逻辑/物理, Retina=0.5
|
||||
|
||||
results = []
|
||||
|
||||
def _walk(el, depth):
|
||||
if depth > max_depth:
|
||||
return
|
||||
role = _ax_attr(el, kAXRoleAttribute)
|
||||
desc = _ax_attr(el, kAXDescriptionAttribute)
|
||||
title = _ax_attr(el, kAXTitleAttribute)
|
||||
ident = _ax_attr(el, 'AXIdentifier')
|
||||
value = _ax_attr(el, 'AXValue')
|
||||
enabled = _ax_attr(el, kAXEnabledAttribute)
|
||||
# AXValue 多为 str/num(文本/输入框);若是 AXValueRef(坐标等)忽略
|
||||
if value is not None and not isinstance(value, (str, int, float, bool)):
|
||||
value = None
|
||||
pos_val = _ax_attr(el, kAXPositionAttribute)
|
||||
sz_val = _ax_attr(el, kAXSizeAttribute)
|
||||
# 解包坐标(逻辑点)
|
||||
px = py_ = pw = ph = 0.0
|
||||
if pos_val is not None:
|
||||
ok, pt = AXValueGetValue(pos_val, kAXValueCGPointType, None)
|
||||
if ok:
|
||||
px, py_ = pt.x, pt.y
|
||||
if sz_val is not None:
|
||||
ok, sz = AXValueGetValue(sz_val, kAXValueCGSizeType, None)
|
||||
if ok:
|
||||
pw, ph = sz.width, sz.height
|
||||
# 转物理像素
|
||||
phys_x = px / scale if scale else px
|
||||
phys_y = py_ / scale if scale else py_
|
||||
phys_w = pw / scale if scale else pw
|
||||
phys_h = ph / scale if scale else ph
|
||||
|
||||
if not include_zero_size and (phys_w <= 0 or phys_h <= 0):
|
||||
pass # 跳过零尺寸,但仍递归子节点
|
||||
else:
|
||||
results.append(dict(
|
||||
depth=depth, role=role, desc=desc, title=title, id=ident,
|
||||
value=value, enabled=bool(enabled) if enabled is not None else None,
|
||||
x=round(phys_x), y=round(phys_y),
|
||||
w=round(phys_w), h=round(phys_h), el=el))
|
||||
for child in (_ax_attr(el, kAXChildrenAttribute) or []):
|
||||
_walk(child, depth + 1)
|
||||
|
||||
for win in wins:
|
||||
_walk(win, 0)
|
||||
return results
|
||||
|
||||
|
||||
def AXPress(element) -> bool:
|
||||
"""对 AX element 执行 Press 动作(免坐标点击)。"""
|
||||
if not _HAS_AX:
|
||||
return False
|
||||
err = AXUIElementPerformAction(element, kAXPressAction)
|
||||
return err == 0
|
||||
|
||||
|
||||
def AXClick(node, check=True) -> bool:
|
||||
"""点击控件: AXPress 优先(免坐标), 失败回退到中心点物理坐标 Click。
|
||||
node: AXFind/AXElements 返回的 dict(含 el 与 x/y/w/h), 或裸 AXUIElement。
|
||||
返回是否点击成功(回退路径据像素变化判定, check=False 时无法判定按 True)。
|
||||
呼应 computer_use SOP: AXPress 优先, 回退 Click(phys_cx, phys_cy)。"""
|
||||
if not isinstance(node, dict):
|
||||
return AXPress(node)
|
||||
if node.get('enabled') is False:
|
||||
print(f"[WARN] AXClick: 控件 disabled (role={node.get('role')}, "
|
||||
f"title={node.get('title')!r}), 点击可能无效")
|
||||
if AXPress(node.get('el')):
|
||||
return True
|
||||
# 回退: 中心点物理坐标
|
||||
cx = node['x'] + node['w'] // 2
|
||||
cy = node['y'] + node['h'] // 2
|
||||
res = Click(cx, cy, check=check)
|
||||
if not check or res is None:
|
||||
return check is False # 无法判定时: 关检查按成功, 截图失败按失败
|
||||
diff, _ = res
|
||||
return diff >= 0.5
|
||||
|
||||
|
||||
def AXFind(target, role=None, desc=None, title=None, identifier=None,
|
||||
enabled_only=False, max_depth=10):
|
||||
"""枚举并过滤控件。所有过滤条件为子串匹配(大小写不敏感)。
|
||||
enabled_only=True 时只返回 enabled 的控件(SOP: 点前查 disabled)。
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict] : 匹配项,同 AXElements 返回格式。
|
||||
"""
|
||||
def _hit(field, needle):
|
||||
return needle is None or (field and needle.lower() in field.lower())
|
||||
return [n for n in AXElements(target, max_depth=max_depth)
|
||||
if _hit(n['role'], role) and _hit(n['desc'], desc)
|
||||
and _hit(n['title'], title) and _hit(n['id'], identifier)
|
||||
and not (enabled_only and n.get('enabled') is False)]
|
||||
|
||||
|
||||
# ---------- API 镜像别名 (drop-in 替换 ljqCtrl 用) ----------
|
||||
click = Click
|
||||
press = Press
|
||||
activate = ActivateApp # 注意: Win 版 Activate(hwnd) 收窗口句柄, Mac 版收应用名子串
|
||||
GrabWindowBg = GrabWindow # Mac screencapture 本身支持后台窗口
|
||||
VK_CODE = _KEYMAP # 名称兼容
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('--- macljqCtrl self-check ---')
|
||||
check_permissions()
|
||||
print('cursor(logical):', _cursor_logical())
|
||||
print('windows(top5):')
|
||||
for w in ListWindows()[:5]:
|
||||
print(' ', w['id'], '|', w['app'], '|', w['title'][:30], '|', w['bbox'])
|
||||
@@ -21,9 +21,11 @@ ROI = (不放这几个词的犯错概率 × 代价) / 每轮词数成本
|
||||
|
||||
## 压缩四原则
|
||||
1. **命名自解释 > 加描述**:SOP名能说清的,L1不加注释;改名的ROI常高于改L1
|
||||
- 推论:极低ROI且文件名自解释的条目可不单独列入L1,但仍需被原则2的集合包含住,靠集合触发词+ls兜底
|
||||
2. **存在性集合最小描述**:多个相近条目若可被同一上位场景覆盖,用集合名表达这类能力的存在,不必平铺子项。如`qq操作/飞书操作/企微操作`→`im操作:*_im_sop`;子项名自解释则只列名不翻译
|
||||
3. **条目 = 场景↔方案存在性**:如`视频理解:yt-dlp取字幕`、`fofa(资产测绘)`——场景名是触发词,方案名编码存在性;括号内**只放反直觉触发词**,非反直觉的(纯翻译/内容描述/实现细节)全是浪费
|
||||
4. **分层归位**:带行为规则或高频高ROI的条目放上方场景行,纯存在性指针归L2/L3平铺列表
|
||||
- **触发词判定**:假设用户说出这个词,你能否想到去查对应SOP?能→直觉(不需要);不能→反直觉(必须留)。如`game_download(百度网盘)`:用户说"百度网盘下载",没这个词你不会想到game_download→必须留
|
||||
4. **分层归位**:L1内部调整位置——带行为规则或高频高ROI的条目放上方场景行,纯存在性指针归L1下方平铺列表。**归位≠移出L1,存在性不可丢**
|
||||
|
||||
## 整理流程
|
||||
1. 逐行读L1,按`|`拆片段,先分类:存在性指针 / RULES / 翻译 / 内容描述 / 实现细节 / 冗余
|
||||
@@ -31,7 +33,9 @@ ROI = (不放这几个词的犯错概率 × 代价) / 每轮词数成本
|
||||
- 全局高ROI → 留
|
||||
- 特定场景 / 低危险 → 降级到L3或删除
|
||||
3. 再清存在性指针:检查是否在表达**场景↔方案存在性**;场景触发词只在**反直觉**时才加,翻译/内容描述/实现细节删掉
|
||||
4. 检查L3文件名是否自解释;能靠改名解决的,不靠L1加描述;最后验证总行数 ≤ 30
|
||||
4. 审计幽灵条目:L1指向的L3文件是否真实存在?不存在则按ROI决定删除或补建文件
|
||||
5. 禁值存储:L1括号内禁放参数值(IP/端口/凭证等),这些属于L2,L1只编码存在性
|
||||
6. 检查L3文件名是否自解释;能靠改名解决的,不靠L1加描述;最后验证总行数 ≤ 30
|
||||
|
||||
**红线**:记忆修改是持久性伤害,错误每轮复利。L1只能patch词级别修改,禁overwrite
|
||||
产生误导应及时修正L1或记忆更名
|
||||
@@ -0,0 +1,30 @@
|
||||
# Project Mode SOP
|
||||
|
||||
## 定义
|
||||
|
||||
Project Mode = 跨会话保持项目认知的工作模式
|
||||
载体:`project_memory.md` + 项目私域目录。两层注入:每轮自动注入的只有 L1(规则+记忆文件指针),L2(`project_memory.md` 全文)不注入——任务涉及项目上下文时自己用 file 工具去读,无关则不读
|
||||
|
||||
## 进入
|
||||
|
||||
锚 = `./.active_project.<宿主pid>`,只对当前 GA 进程有效:GA 关闭即自动失活;多开 GA 各自激活不同项目,互不干扰。(下文路径一律以 cwd 为基准,cwd 即 GA 的 temp 目录;禁写 `temp/xxx` 前缀,会嵌套出 temp/temp)
|
||||
|
||||
- 用户只说「进入项目模式」未指明项目:列出 `./projects/` 下各项目(名字 + memory 行数 + 最后修改时间),ask_user 让用户选定后再继续
|
||||
- 用户明确说「进入/切换到 <项目名> 项目」:视为已确认,直接执行:
|
||||
|
||||
1. 建目录 `./projects/<项目名>/`,无则创建 `project_memory.md`(空文件即可)
|
||||
2. 写文件锚,必须用 code_run(锚文件名含宿主 pid,ppid 即 GA 宿主进程):
|
||||
`import os; open(f'./.active_project.{os.getppid()}', 'w').write('<项目名>')`
|
||||
3. 回读 `project_memory.md` 全文,向用户复述项目现状
|
||||
|
||||
## 期间纪律
|
||||
|
||||
- 项目文件(todo、草稿、产物)一律放 `./projects/<项目名>/`,禁止丢 temp 根目录
|
||||
- 入库判据(唯一标准):每得到一条信息,自问「记忆归零、重新接手本项目的我,缺了这条会不会重复付出认知代价——再踩一次坑、再摸索一次、再问一次用户?」会则立即追加进 `project_memory.md`,不会则不记
|
||||
- 一条一句,写成未来的自己能直接复用的形式;已有条目增量更新,不整篇重写
|
||||
|
||||
## 离开
|
||||
|
||||
用户表示「离开项目模式」时:删除 `./.active_project.<宿主pid>`(仅关闭激活态,项目目录与 `project_memory.md` 原样保留)
|
||||
切换到另一项目时无需先离开,直接按「进入」覆盖文件锚即可
|
||||
GA 关闭不需任何操作:锚随进程消亡自动失效,残留文件由插件下次启动时清扫
|
||||
@@ -1,64 +0,0 @@
|
||||
# Skill Search — 105K 技能卡检索
|
||||
|
||||
> 从 105K+ 技能卡中语义搜索最匹配的 skill。零依赖,内置默认 API 地址,开箱即用。
|
||||
|
||||
## 最简调用
|
||||
|
||||
```python
|
||||
import sys; sys.path.append('../memory/skill_search')
|
||||
from skill_search import search
|
||||
|
||||
results = search("python send email") # ⚠️ 必须用英文查询,中文匹配效果极差
|
||||
for r in results:
|
||||
s = r.skill
|
||||
print(f"[{r.final_score:.2f}] {s.name} — {s.one_line_summary}")
|
||||
print(f" key: {s.key} category: {s.category} tags: {s.tags[:3]}")
|
||||
```
|
||||
|
||||
## API 签名
|
||||
|
||||
```python
|
||||
search(query, env=None, category=None, top_k=10) -> list[SearchResult]
|
||||
# env: 自动检测,一般不传
|
||||
# category: 可选过滤,如 "devops"
|
||||
# top_k: 返回数量,默认10
|
||||
```
|
||||
|
||||
## 返回结构
|
||||
|
||||
```
|
||||
SearchResult
|
||||
.final_score float 综合评分 (0~1)
|
||||
.relevance float 语义相关度
|
||||
.quality float 质量分
|
||||
.match_reasons list[str] 匹配原因
|
||||
.warnings list[str] 警告
|
||||
.skill SkillIndex ↓
|
||||
|
||||
SkillIndex (常用字段)
|
||||
.key str 唯一标识/路径
|
||||
.name str 名称
|
||||
.one_line_summary str 一句话摘要
|
||||
.description str 详细描述
|
||||
.category str 类别
|
||||
.tags list[str] 标签
|
||||
.form str 形式(sop/script/...)
|
||||
.autonomous_safe bool 是否自主安全
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
python -m skill_search "python testing"
|
||||
python -m skill_search "docker deployment" --category devops --top 5
|
||||
python -m skill_search "git" --json
|
||||
python -m skill_search --stats
|
||||
python -m skill_search --env
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
| 项 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| API地址 | `http://www.fudankw.cn:58787` | 环境变量 `SKILL_SEARCH_API` 可覆盖 |
|
||||
| API密钥 | 无(可选) | 环境变量 `SKILL_SEARCH_KEY` |
|
||||
@@ -1,8 +0,0 @@
|
||||
"""skill_search — Skill 检索 API 客户端"""
|
||||
from .engine import (
|
||||
SkillIndex, SearchResult, SkillSearchError,
|
||||
search, get_stats, detect_environment,
|
||||
)
|
||||
|
||||
__all__ = ["SkillIndex", "SearchResult", "SkillSearchError",
|
||||
"search", "get_stats", "detect_environment"]
|
||||
@@ -1,116 +0,0 @@
|
||||
"""CLI 入口: python -m skill_search"""
|
||||
from __future__ import annotations
|
||||
import argparse, json, sys
|
||||
from .engine import SearchResult, SkillSearchError, detect_environment, search, get_stats
|
||||
|
||||
|
||||
# ── 格式化 ───────────────────────────────────────────────
|
||||
|
||||
def format_results(results: list[SearchResult], env: dict, query: str) -> str:
|
||||
lines = [f'🔍 搜索: "{query}"',
|
||||
f"🖥️ 环境: {env.get('os','?')} / {env.get('shell','?')} / {', '.join(env.get('runtimes',[]))}",
|
||||
f"📊 找到 {len(results)} 个匹配结果\n"]
|
||||
if not results:
|
||||
lines.append("未找到匹配的 skill。试试其他关键词?")
|
||||
return "\n".join(lines)
|
||||
for i, r in enumerate(results, 1):
|
||||
s = r.skill
|
||||
safe_icon = "🟢" if s.autonomous_safe else "🔴"
|
||||
score_bar = "█" * int(r.final_score * 10) + "░" * (10 - int(r.final_score * 10))
|
||||
lines += [
|
||||
f"{'─'*60}",
|
||||
f"#{i} {safe_icon} {s.name}",
|
||||
f" 路径: {s.key}",
|
||||
f" 类别: {s.category} | 标签: {', '.join(s.tags[:5])}",
|
||||
f" 摘要: {s.one_line_summary}",
|
||||
f" 评分: [{score_bar}] {r.final_score:.2f} (相关={r.relevance:.2f} 质量={r.quality:.1f})",
|
||||
f" 清晰={s.clarity} 完整={s.completeness} 可操作={s.actionability} | 形式={s.form}",
|
||||
]
|
||||
if r.match_reasons:
|
||||
lines.append(f" 匹配: {' | '.join(r.match_reasons[:3])}")
|
||||
if r.warnings:
|
||||
lines.extend(f" {w}" for w in r.warnings)
|
||||
lines.append("")
|
||||
lines.append(f"{'─'*60}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_results_json(results: list[SearchResult]) -> list[dict]:
|
||||
out = []
|
||||
for r in results:
|
||||
s = r.skill
|
||||
out.append({
|
||||
"rank": len(out) + 1, "key": s.key, "name": s.name,
|
||||
"category": s.category, "tags": s.tags,
|
||||
"description": s.description, "one_line_summary": s.one_line_summary,
|
||||
"scores": {"final": round(r.final_score, 3), "relevance": round(r.relevance, 3),
|
||||
"quality": round(r.quality, 1), "clarity": s.clarity,
|
||||
"completeness": s.completeness, "actionability": s.actionability},
|
||||
"safety": {"autonomous_safe": s.autonomous_safe, "blast_radius": s.blast_radius,
|
||||
"requires_credentials": s.requires_credentials,
|
||||
"data_exposure": s.data_exposure, "effect_scope": s.effect_scope},
|
||||
"platform": {"os": s.os, "runtimes": s.runtimes, "tools": s.tools, "services": s.services},
|
||||
"warnings": r.warnings, "match_reasons": r.match_reasons,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog="skill_search",
|
||||
description="Skill 检索系统 — 根据环境和需求智能推荐 skill(API 客户端)")
|
||||
parser.add_argument("query", nargs="?", help="搜索关键词(如: 'python testing')")
|
||||
parser.add_argument("--category", "-cat", help="限定类别")
|
||||
parser.add_argument("--top", "-k", type=int, default=10, help="返回结果数(默认 10)")
|
||||
parser.add_argument("--json", action="store_true", help="JSON 格式输出")
|
||||
parser.add_argument("--env", action="store_true", help="仅显示检测到的环境信息")
|
||||
parser.add_argument("--stats", action="store_true", help="显示索引统计信息")
|
||||
parser.add_argument("--api-url", help="指定 API 地址(也可用 SKILL_SEARCH_API 环境变量)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.api_url:
|
||||
import os; os.environ["SKILL_SEARCH_API"] = args.api_url
|
||||
|
||||
env = detect_environment()
|
||||
|
||||
if args.env:
|
||||
print("🖥️ 当前环境:")
|
||||
print(f" OS: {env['os']}")
|
||||
print(f" Shell: {env['shell']}")
|
||||
print(f" 运行时: {', '.join(env['runtimes'])}")
|
||||
print(f" 工具: {', '.join(env['tools'])}")
|
||||
print(f" 模型能力: tool_calling={env['model']['tool_calling']}, "
|
||||
f"reasoning={env['model']['reasoning']}, context={env['model']['context_window']}")
|
||||
return
|
||||
|
||||
if args.stats:
|
||||
try:
|
||||
stats = get_stats(env)
|
||||
print(f"📊 索引统计:")
|
||||
print(f" 总计: {stats.get('total', '?')} 个 skills")
|
||||
print(f" 自动安全: {stats.get('safe_count', '?')} 个")
|
||||
if 'categories' in stats:
|
||||
print(f" 类别分布:")
|
||||
for cat, cnt in sorted(stats['categories'].items(), key=lambda x: -x[1]):
|
||||
print(f" {cat:15s} {cnt:4d}")
|
||||
except SkillSearchError as e:
|
||||
print(f"❌ {e}", file=sys.stderr); sys.exit(1)
|
||||
return
|
||||
|
||||
if not args.query:
|
||||
parser.print_help(); return
|
||||
|
||||
try:
|
||||
results = search(query=args.query, env=env, category=args.category, top_k=args.top)
|
||||
except SkillSearchError as e:
|
||||
print(f"❌ {e}", file=sys.stderr); sys.exit(1)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(format_results_json(results), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(format_results(results, env, args.query))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Skill 检索引擎 — API 客户端(含数据模型与环境检测)"""
|
||||
from __future__ import annotations
|
||||
import json, os, platform, shutil, subprocess, urllib.request, urllib.error
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ── 数据模型 ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class SkillIndex:
|
||||
"""Skill 索引条目(与服务端结构对齐)"""
|
||||
key: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
one_line_summary: str = ""
|
||||
category: str = ""
|
||||
tags: list[str] = field(default_factory=list)
|
||||
language: str = "en"
|
||||
os: list[str] = field(default_factory=list)
|
||||
shell: list[str] = field(default_factory=list)
|
||||
runtimes: list[str] = field(default_factory=list)
|
||||
tools: list[str] = field(default_factory=list)
|
||||
services: list[str] = field(default_factory=list)
|
||||
needs_tool_calling: bool = False
|
||||
needs_reasoning: bool = False
|
||||
min_context_window: str = "standard"
|
||||
decay_risk: str = "low"
|
||||
clarity: int = 0
|
||||
completeness: int = 0
|
||||
actionability: int = 0
|
||||
autonomous_safe: bool = True
|
||||
blast_radius: str = "low"
|
||||
requires_credentials: bool = False
|
||||
data_exposure: str = "none"
|
||||
effect_scope: str = "local"
|
||||
form: str = ""
|
||||
estimated_tokens: str = "medium"
|
||||
capabilities: list[str] = field(default_factory=list)
|
||||
github_stars: int = 0
|
||||
github_url: str = ""
|
||||
|
||||
@property
|
||||
def quality_score(self):
|
||||
return self.clarity * 0.3 + self.completeness * 0.3 + self.actionability * 0.4
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
known = {f.name for f in cls.__dataclass_fields__.values()}
|
||||
return cls(**{k: v for k, v in d.items() if k in known})
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""单条检索结果"""
|
||||
skill: SkillIndex
|
||||
relevance: float = 0.0
|
||||
quality: float = 0.0
|
||||
final_score: float = 0.0
|
||||
match_reasons: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
skill = SkillIndex.from_dict(d.get("skill", d))
|
||||
return cls(skill=skill, relevance=d.get("relevance", 0.0),
|
||||
quality=d.get("quality", 0.0), final_score=d.get("final_score", 0.0),
|
||||
match_reasons=d.get("match_reasons", []), warnings=d.get("warnings", []))
|
||||
|
||||
|
||||
# ── 环境检测 ─────────────────────────────────────────────
|
||||
|
||||
def _run(cmd):
|
||||
try:
|
||||
r = subprocess.run(cmd.split(), capture_output=True, text=True, timeout=5)
|
||||
return r.stdout.strip() if r.returncode == 0 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _detect_os():
|
||||
s = platform.system().lower()
|
||||
return {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(s, s)
|
||||
|
||||
def _detect_shell():
|
||||
shell = os.environ.get("SHELL", "")
|
||||
if "zsh" in shell: return "zsh"
|
||||
if "bash" in shell: return "bash"
|
||||
if platform.system() == "Windows": return "powershell"
|
||||
return os.path.basename(shell) if shell else "unknown"
|
||||
|
||||
def _detect_runtimes():
|
||||
checks = {"python": ["python3", "python"], "node": ["node"], "go": ["go"],
|
||||
"rust": ["rustc"], "java": ["java"], "ruby": ["ruby"],
|
||||
"php": ["php"], "dotnet": ["dotnet"]}
|
||||
found = []
|
||||
for name, cmds in checks.items():
|
||||
for cmd in cmds:
|
||||
if shutil.which(cmd):
|
||||
found.append(name); break
|
||||
return found
|
||||
|
||||
def _detect_tools():
|
||||
tools = ["git", "docker", "npm", "pip", "curl", "wget", "kubectl",
|
||||
"terraform", "aws", "gcloud", "az", "brew", "cargo", "make", "cmake"]
|
||||
return [t for t in tools if shutil.which(t)]
|
||||
|
||||
def detect_environment():
|
||||
"""采集完整环境信息"""
|
||||
return {"os": _detect_os(), "shell": _detect_shell(),
|
||||
"runtimes": _detect_runtimes(), "tools": _detect_tools(),
|
||||
"model": {"tool_calling": True, "reasoning": True, "context_window": "large"}}
|
||||
|
||||
|
||||
# ── API 配置与调用 ────────────────────────────────────────
|
||||
|
||||
DEFAULT_API_URL = "http://www.fudankw.cn:58787"
|
||||
|
||||
def _get_api_url():
|
||||
return os.environ.get("SKILL_SEARCH_API", DEFAULT_API_URL)
|
||||
|
||||
def _get_api_key():
|
||||
return os.environ.get("SKILL_SEARCH_KEY")
|
||||
|
||||
class SkillSearchError(Exception):
|
||||
pass
|
||||
|
||||
def _api_request(endpoint, payload):
|
||||
url = f"{_get_api_url()}/{endpoint}"
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = _get_api_key()
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
raise SkillSearchError(f"API 错误 {e.code}: {body}") from e
|
||||
except urllib.error.URLError as e:
|
||||
raise SkillSearchError(f"无法连接服务: {e.reason}") from e
|
||||
except Exception as e:
|
||||
raise SkillSearchError(f"请求失败: {e}") from e
|
||||
|
||||
|
||||
# ── 公开接口 ─────────────────────────────────────────────
|
||||
|
||||
def search(query, env=None, category=None, top_k=10):
|
||||
if env is None: env = detect_environment()
|
||||
payload = {"query": query, "env": env, "top_k": top_k}
|
||||
if category: payload["category"] = category
|
||||
resp = _api_request("search", payload)
|
||||
return [SearchResult.from_dict(r) for r in resp.get("results", [])]
|
||||
|
||||
def get_stats(env=None):
|
||||
if env is None: env = detect_environment()
|
||||
return _api_request("stats", {"env": env})
|
||||
+5
-5
@@ -6,13 +6,13 @@
|
||||
- 启动:`python agentmain.py --task {name} [--input "短文本"] [--llm_no N]`(cwd=代码根)
|
||||
- `--input`自动建目录+清旧output+写input.txt;长文本先手动写input.txt再启动(不带--input)
|
||||
- 自动后台启动,print PID then exit
|
||||
- subagent的cwd还是temp,不是task目录
|
||||
- 统一设定:所有agent的cwd都是temp,方便文件共享,不是task目录
|
||||
- input:目标+约束即可,subagent同等智能。**禁写步骤/过度描述**,大量数据给路径
|
||||
- 可选fork功能(继承对话上下文): code_run(inline_eval=True),将变量history(自动注入,str)写入task目录下_history.json
|
||||
- 通信:output.txt(append,`[ROUND END]`=轮完成) → 写reply.txt继续 → 不写10min退出。reply后输出为output1/2/3.txt(同格式)
|
||||
- 干预文件:`_stop`(当轮结束) | `_keyinfo`(注入working memory) | `_intervene`(追加指令)
|
||||
- 监察模式:**主agent空闲时应读output观察进度,必要时用干预文件纠偏,禁止无脑长时间sleep**
|
||||
- 若加`--verbose`,output将包含工具执行结果,主agent可直接审查原始数据而非仅信任摘要
|
||||
- [[可选fork功能]](继承对话上下文): 事先code_run(inline_eval=True),将变量history(自动注入,str)写入task目录下_history.json
|
||||
- [[可选监察者模式]]:**主agent空闲时应读output观察进度,必要时用干预文件纠偏,禁止无脑长时间sleep**
|
||||
若加`--verbose`,output将包含工具执行结果,主agent可直接审查原始数据而非仅信任摘要
|
||||
|
||||
## 场景1:测试模式 - 行为验证
|
||||
**用途**:观察agent真实行为,修正RULES/L2/L3/SOP
|
||||
@@ -29,7 +29,7 @@
|
||||
**核心优势**:独立上下文。避免处理文档A的长上下文污染处理文档B的质量
|
||||
**约束**:
|
||||
- 文件系统共享是优点:不同agent处理不同输入文件,产生不同输出文件
|
||||
- 共享资源冲突:键鼠不可共享;浏览器暂时不可并行使用,避免同时操作同一标签页
|
||||
- 共享资源冲突:键鼠不可共享;浏览器避免操作同一tab
|
||||
- 不满足map模式的任务 → 主agent顺序执行即可,别用subagent
|
||||
**标准流程(map-reduce)**:
|
||||
1. 主agent准备阶段:爬取/dump数据,存为多个独立输入文件
|
||||
|
||||
+47
-30
@@ -1,42 +1,59 @@
|
||||
# 监察者模式 SOP
|
||||
|
||||
> 你是挑刺的监工,不是干活的工人。你的唯一任务:确保工作agent高质量完成任务。有SOP按SOP约束,无SOP凭常理和经验把关。
|
||||
目标:让用户一次说明任务后,尽量不用多轮纠偏。
|
||||
核心:**先定行动协议,再盯协议偏离,最后看完成证据。**
|
||||
|
||||
## 红线
|
||||
## 1. 定位
|
||||
|
||||
- **禁止下场干活**:不操作浏览器、不写代码、不执行任务步骤。你只读、只判断、只干预
|
||||
- **可以读环境**:file_read/web_scan/web_execute_js/code_run(只读命令)获取情报,辅助判断工作agent进度和状态
|
||||
监察者不是工人。你是挑刺的监工,只负责看 subagent 是否按目标、约束和证据闭环完成任务;有 SOP 按 SOP 把关,无 SOP 按常理和经验把关。
|
||||
|
||||
## 启动
|
||||
## 2. 红线
|
||||
|
||||
1. **有SOP时**:读SOP原文,提取所有约束(⚠️/禁止/必须/格式要求),按步骤列成**约束清单**存working memory
|
||||
1. **无SOP时**:根据任务性质和进度,预估未来会遇到的关键风险点
|
||||
2. **启动subagent**(cwd=代码根):
|
||||
```
|
||||
python agentmain.py --task {name} --verbose
|
||||
```
|
||||
input.txt:`用{SOP名}完成{用户任务}`(只给目标,不复述步骤)
|
||||
- **禁止下场干活**:不替 subagent 操作浏览器、不写代码、不执行任务步骤。
|
||||
- **只读探测**:可用 `file_read`、`web_scan`、`web_execute_js`、只读 `code_run` 辅助判断对象、进度和证据;探测不是代做。
|
||||
- **沉默为主**:没发现会导致用户纠偏的问题,就不说话。
|
||||
- **一句话干预**:必须短、硬、具体,像用户直接纠偏,禁长篇教程。
|
||||
- **`_keyinfo` 只用于预注入**:在 subagent 到达关键步骤前提醒;已经犯错必须用 `_intervene`。
|
||||
|
||||
## 监控循环
|
||||
## 3. 执行顺序(硬性,不可跳步)
|
||||
|
||||
持续轮询 `temp/{task_name}/output.txt` 的新增内容(sleep间隔读取),每发现新输出:
|
||||
**Step A — 立刻启动 subagent**(纯机械,不需思考)
|
||||
|
||||
1. 判断工作agent当前在哪一步,对照约束清单检查(约束记不清时重读SOP原文,禁凭印象)
|
||||
2. 可读环境信息(文件/网页/进程)补充判断依据
|
||||
3. 工作agent ask_user时给予回复
|
||||
读完本 SOP 和 `subagent.md` 后,从用户 prompt 中剥离任务,写 input,启动。调用细节见 `subagent.md`;input 只给目标+约束,长文本给路径。
|
||||
|
||||
| 发现 | 干预 |
|
||||
|------|------|
|
||||
| 跳步 | `_intervene`:你跳过了StepN,先做 |
|
||||
| 细节遗漏 | `_intervene`:你漏了XX约束,重做/补上 |
|
||||
| 光说不做 | `_intervene`:别说了,直接做 |
|
||||
| 断言无据 | `_intervene`:你怎么确认的?验证一下 |
|
||||
| 连续失败 | `_intervene`:停,先读错误日志再决定 |
|
||||
| 感觉要偏 | `_intervene`:去重读SOP的StepN再继续 |
|
||||
| 即将进入中后期步骤 | `_keyinfo`:提前注入该步骤的⚠️细节(趁还没到,先塞进working memory) |
|
||||
**Step B — 写预期文档**(利用 subagent 启动+读环境的时间)
|
||||
|
||||
## 干预原则
|
||||
subagent 在跑前几轮环境探测时,你写一屏预期文档(产出文件),只写三项:
|
||||
|
||||
- **沉默为主**:没问题不说话
|
||||
- **一句话**:像用户一样直接说,禁长篇解释
|
||||
- **`_keyinfo`只用于提前预注入**:在工作agent到达该步之前塞细节。已经犯错的一律用`_intervene`纠正
|
||||
1. **意图与锚点**:目标、必须先看的对象、明显不是目标的对象;只列显性或高代价的账号/身份/归属字段。
|
||||
2. **行动协议**:正确推进顺序;必须先找/读哪些 SOP 或工具说明,并把其中的禁止/必须/格式要求纳入约束;何时核对对象/路径/账号/窗口/字段;危险动作前验证什么;调研/测试类先跑通最小可复现闭环再扩大。
|
||||
3. **完成证据**:最后必须有什么证据才能交付:状态、截图、日志、diff、测试结果、报告路径、使用方法等。
|
||||
|
||||
**Step C — tail 监控与干预**(预期文档写完后开始)
|
||||
|
||||
轮询 `temp/{task_name}/output*.txt`。看到 `[ROUND END]` 后判断是否需要写 `reply.txt` 续聊或用干预文件纠偏。tail 中若发现新信息(真实文件结构、字段、约束、隐藏难点),回头补充/更新预期文档,尤其补全此前想不到的验收指标。tail 时盯这些偏离:
|
||||
|
||||
- 没先落到用户锚点,凭记忆/猜测行动。
|
||||
- 没找应有 SOP/工具说明,或读了却没按其中禁止/必须/格式要求做。
|
||||
- 对象、路径、窗口、账号、环境、数据源偏了。
|
||||
- 顺序错,尤其危险/不可逆动作前没验证。
|
||||
- 粒度错:该先跑通却大设计,该最小改动却重构,该调研测试却泛泛写报告。
|
||||
- 要交付了,但证据、路径、格式、测试或说明不够。
|
||||
- 反复试错、泛泛搜索、卡住不读日志/不看 SOP/不收敛到下一步。
|
||||
|
||||
干预示例:
|
||||
|
||||
```text
|
||||
停。用户锚点是 X,不是 Y。先看 X。
|
||||
不要提交。危险动作前还没验证 X。
|
||||
粒度错了。先跑通最小闭环。
|
||||
不要交付。还缺 X 证据/路径/格式。
|
||||
先停。这个任务应先读/遵守 X SOP,把禁止/必须项列进约束。
|
||||
你漏了 XX 约束,补上后再继续。
|
||||
连续失败先停,读错误日志再决定。
|
||||
别继续乱试。先读 X SOP/日志,列出一个最小下一步再做。
|
||||
```
|
||||
|
||||
## 4. 最终验收
|
||||
|
||||
按预期文档逐条核对:锚点是否命中、行动协议是否遵守、危险前是否验证、最终产物/结果是否满足用户目标、质量是否够、证据是否足够且路径/格式明确。任一不满足就返工;全部满足才交付用户。
|
||||
+6
-2
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
UI元素检测 - YOLO + RapidOCR
|
||||
UI元素检测 - 基于 OmniParser-v2.0 的 icon_detect YOLO 模型 + RapidOCR
|
||||
|
||||
首次使用前必须准备对应权重;这是 OmniParser-v2.0 的 icon_detect YOLO 模型。
|
||||
若缺失模型文件,新用户/AI 应搜索并下载 OmniParser-v2.0 icon_detect YOLO 权重。
|
||||
|
||||
用法:
|
||||
from ui_detect import detect
|
||||
elements = detect("screenshot.png") # 默认match模式
|
||||
@@ -157,7 +161,7 @@ def detect(image_path, mode='match', conf=0.25, iou_thresh=0.5):
|
||||
return elements
|
||||
|
||||
def visualize_for_debug(image_path, elements, output_path=None):
|
||||
"""调试用: 可视化元素列表"""
|
||||
"""Only use when user wants to DEBUG!"""
|
||||
from PIL import ImageFont
|
||||
img = Image.open(image_path)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
@@ -3,6 +3,12 @@ from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
# ============ 用户配置区(从 template 拷贝后只需改这里)============
|
||||
# GA自举提示:
|
||||
# mykey.py配置变量名不固定;默认值失败时,枚举候选变量名,并用最小vision请求实验探测哪组配置可用。
|
||||
# 只打印变量名/字段名/model/apibase域名路径/HTTP状态码/错误类型,禁止打印完整dict和apikey/token。
|
||||
# 常见格式:
|
||||
# xxx_config = {"apibase": "https://.../v1", "apikey": "...", "model": "...", "proxy": None}
|
||||
# 不同中转的 apibase/endpoint 可能不同,按实际状态码和响应结构修正。
|
||||
CLAUDE_CONFIG_KEY = 'claude_config141' # mykey.py 中 Claude 配置的变量名
|
||||
OPENAI_CONFIG_KEY = 'oai_config1' # mykey.py 中 OpenAI 配置的变量名
|
||||
MODELSCOPE_API_KEY = '' # 直接填你的 ModelScope token
|
||||
@@ -79,7 +85,7 @@ def _call_claude(b64, prompt, timeout, max_tokens=1024):
|
||||
mk = _load_config()
|
||||
cfg = getattr(mk, CLAUDE_CONFIG_KEY)
|
||||
resp = requests.post(
|
||||
cfg['apibase'] + '/v1/messages',
|
||||
cfg['apibase'] + '/v1/messages', # endpoint按中转实际情况改:有的apibase已含/v1,或路径不同
|
||||
json={'model': cfg['model'], 'max_tokens': max_tokens, 'messages': [{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
@@ -96,7 +102,7 @@ def _call_claude(b64, prompt, timeout, max_tokens=1024):
|
||||
def _call_openai_compat(b64, prompt, timeout, *, apibase, apikey, model, proxy=None):
|
||||
proxies = {'https': proxy, 'http': proxy} if proxy else None
|
||||
resp = requests.post(
|
||||
apibase.rstrip('/') + '/v1/chat/completions',
|
||||
apibase.rstrip('/') + '/v1/chat/completions', # endpoint按中转实际情况改:有的apibase已含/v1,或路径不同
|
||||
json={'model': model, 'messages': [{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
|
||||
+7
-7
@@ -253,13 +253,13 @@ mixin_config = {
|
||||
# MiniMax 同时提供 OAI 和 Anthropic 兼容接口,同一个 key 两个端点都能用:
|
||||
# - /v1 → chat/completions (LLMSession)
|
||||
# - /anthropic → Anthropic Messages (NativeClaudeSession)
|
||||
# Anthropic 路径更简洁,OAI 路径会返回 <think> 标签(M2.7 自带思考)。
|
||||
# 温度自动修正为 (0, 1],支持 M2.7 / M2.5 全系列,204K 上下文。
|
||||
# Anthropic 路径更简洁,OAI 路径会返回 <think> 标签(M3/M2.7 自带思考)。
|
||||
# 温度自动修正为 (0, 1],支持 M3 / M2.7 全系列,M3 512K 上下文。
|
||||
# native_claude_config_minimax = {
|
||||
# 'name': 'minimax-anthropic', # /llms 显示名
|
||||
# 'apikey': 'sk-<your-minimax-key>', # 与 OAI 路径同一个 key
|
||||
# 'apibase': 'https://api.minimaxi.com/anthropic', # Anthropic Messages 兼容端点
|
||||
# 'model': 'MiniMax-M2.7',
|
||||
# 'model': 'MiniMax-M3',
|
||||
# 'max_retries': 3, # int
|
||||
# # 'fake_cc_system_prompt': False, # MiniMax 不做 CC 指纹校验
|
||||
# }
|
||||
@@ -358,14 +358,14 @@ mixin_config = {
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── 4a. MiniMax OAI 路径 (/v1 chat/completions) ────────────────────────────
|
||||
# OAI 路径会返回 <think> 标签(M2.7 自带思考);Anthropic 路径更简洁(见 1f)。
|
||||
# 温度自动修正为 (0, 1],支持 M2.7/M2.5 全系列,204K 上下文。
|
||||
# OAI 路径会返回 <think> 标签(M3/M2.7 自带思考);Anthropic 路径更简洁(见 1f)。
|
||||
# 温度自动修正为 (0, 1],支持 M3/M2.7 全系列,M3 512K 上下文。
|
||||
# oai_config_minimax = {
|
||||
# 'name': 'minimax-oai', # /llms 显示名
|
||||
# 'apikey': 'sk-<your-minimax-key>', # 形如 sk-cp-xxxxxxxxx;Bearer 鉴权
|
||||
# 'apibase': 'https://api.minimaxi.com/v1', # OAI 兼容端点
|
||||
# 'model': 'MiniMax-M2.7', # 名含 'minimax' → temp 夹到 (0.01,1.0]
|
||||
# 'context_win': 50000, # int;MiniMax 204K 上下文,此处是裁剪阈值
|
||||
# 'model': 'MiniMax-M3', # 名含 'minimax' → temp 夹到 (0.01,1.0]
|
||||
# 'context_win': 50000, # int;MiniMax M3 512K 上下文,此处是裁剪阈值
|
||||
# }
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Project Mode plugin — 零核心代码改动实现 GA 项目模式。
|
||||
|
||||
机制:注册 agent_before hook(agent_loop.py 中每个用户轮触发一次)。
|
||||
当项目模式激活时,把 L1 层(规则 + 记忆文件指针 + 收尾纪律)追加到最后一条 user message(str 直接拼接,多模态 list 追加 text block)。
|
||||
两层设计:L1 每轮全量注入(轻量、稳定);L2 = project_memory.md 全文不注入,
|
||||
由模型按 L1 中的指针与线索(行数/大小)自行判断是否用 file 工具读取。
|
||||
利用 messages 是 list 引用的事实,直接 mutate 即反映到真正发给 LLM 的内容。
|
||||
|
||||
激活态载体 = 文件锚 temp/.active_project.<宿主pid>(存当前项目名)。PID 键控:
|
||||
- 锚只对写它的那个 GA 进程有效 → 多开 GA 各自激活不同项目,互不可见
|
||||
- GA 关闭即自动失活(重启后 pid 变,旧锚作废);重新激活需经用户确认(SOP 指示)
|
||||
- 进入:agent 读 project_mode_sop,经用户确认后写锚(code_run 中 os.getppid() 即宿主 pid)
|
||||
- 退出:删除该文件。插件加载时清扫旧版无后缀锚与自己 pid 的前世残留(不碰他进程的锚)
|
||||
|
||||
目录约定:
|
||||
temp/projects/<项目名>/project_memory.md 单文件全文注入的项目记忆
|
||||
temp/projects/<项目名>/ 项目私域文件(todo 等),解决多项目覆盖
|
||||
"""
|
||||
import os
|
||||
import plugins.hooks as hooks
|
||||
|
||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_TEMP = os.path.join(_PROJECT_ROOT, 'temp')
|
||||
_ANCHOR = os.path.join(_TEMP, f'.active_project.{os.getpid()}')
|
||||
|
||||
|
||||
def _cleanup_stale_anchors():
|
||||
"""清扫无主锚:仅删两类能纯靠文件名判定的——旧版无后缀的、自己 pid 的(刚启动
|
||||
不可能写过锚,必是 pid 复用的前世残留)。他进程的锚一律不碰:故意不做存活探测,
|
||||
os.kill(pid, 0) 在 Windows 上语义是终止进程而非探活,任何平台分支都不值得为
|
||||
删除无害残留文件引入杀进程风险(激活判定只认自己 pid 的锚,残留不影响行为)。"""
|
||||
import glob
|
||||
for path in glob.glob(os.path.join(_TEMP, '.active_project*')):
|
||||
pid = path.rsplit('.', 1)[-1]
|
||||
if path != _ANCHOR and pid.isdigit():
|
||||
continue # 他进程的锚(含已死进程的),不碰
|
||||
try: os.remove(path)
|
||||
except OSError: pass
|
||||
|
||||
|
||||
_cleanup_stale_anchors()
|
||||
|
||||
|
||||
def _active_project(ctx=None):
|
||||
"""返回当前激活的项目名;未激活返回 None。
|
||||
|
||||
兼容策略:
|
||||
- 新 TUI 多会话可在当前 GenericAgent 实例上设置 _ga_project_mode_name。
|
||||
只要该属性存在,就以它为准;值为 None/空串表示该 agent 普通模式。
|
||||
- 其它 UI / 旧 SOP 不设置该属性,继续读取 pid 键控文件锚。
|
||||
异常不在此捕获——hooks.trigger 统一捕获并打印,保持可观测。"""
|
||||
parent = None
|
||||
if isinstance(ctx, dict):
|
||||
handler = ctx.get('handler')
|
||||
parent = getattr(handler, 'parent', None)
|
||||
if parent is not None and hasattr(parent, '_ga_project_mode_name'):
|
||||
return getattr(parent, '_ga_project_mode_name', None) or None
|
||||
if not os.path.isfile(_ANCHOR):
|
||||
return None
|
||||
return open(_ANCHOR, encoding='utf-8').read().strip() or None
|
||||
|
||||
|
||||
def _project_dir(name):
|
||||
return os.path.join(_TEMP, 'projects', name)
|
||||
|
||||
|
||||
def _mem_path(name):
|
||||
return os.path.join(_project_dir(name), 'project_memory.md')
|
||||
|
||||
|
||||
def _memory_stat(name):
|
||||
"""返回 project_memory.md 的 (存在, 行数, 字节数),供 L1 指针给模型判断依据。"""
|
||||
path = _mem_path(name)
|
||||
if os.path.isfile(path):
|
||||
data = open(path, encoding='utf-8').read()
|
||||
return True, len(data.splitlines()), len(data.encode('utf-8'))
|
||||
return False, 0, 0
|
||||
|
||||
|
||||
def _build_injection(name):
|
||||
"""构造追加到 user message 末尾的内容(两层设计的 L1 层)。
|
||||
|
||||
L1(每轮全量注入):规范/规则/操作说明 + 记忆文件指针(含行数/大小线索)。
|
||||
L2(按需):project_memory.md 全文不注入,由模型自行判断是否用 file 工具去读。
|
||||
"""
|
||||
pdir = _project_dir(name)
|
||||
mem_path = _mem_path(name)
|
||||
exists, lines, nbytes = _memory_stat(name)
|
||||
if exists and nbytes > 0:
|
||||
mem_hint = (
|
||||
f"项目全量记忆在 {mem_path}({lines} 行 / {nbytes} 字节)。"
|
||||
f"本轮任务若涉及项目上下文(接续工作、查约定、避坑),先读它再动手;"
|
||||
f"若与项目认知无关(闲聊、独立小事),可不读。自行判断。"
|
||||
)
|
||||
else:
|
||||
mem_hint = f"项目记忆 {mem_path} 暂为空(本项目尚无沉淀),无需读取。"
|
||||
return (
|
||||
f"\n\n---\n"
|
||||
f"[PROJECT MODE: {name}]\n"
|
||||
f"你正在「{name}」项目模式中。\n\n"
|
||||
f"## 规则\n"
|
||||
f"- 项目私域目录:{pdir}(todo、草稿、产物一律放这里,勿放 temp 根目录)\n"
|
||||
f"- {mem_hint}\n\n"
|
||||
f"## 收尾纪律\n"
|
||||
f"干完本轮活后自问一个问题:「记忆归零、重新接手本项目的我,缺了本轮哪条信息会重复付出认知代价"
|
||||
f"——再踩一次坑、再摸索一次、再问一次用户?」会的,就用 file 工具把那条追加进 {mem_path},"
|
||||
f"写成未来的自己能直接复用的一句话;不会的,一个字都不写。\n"
|
||||
f"---"
|
||||
)
|
||||
|
||||
|
||||
@hooks.register('agent_before')
|
||||
def inject_project_context(ctx):
|
||||
"""每个用户轮起始时,若项目模式激活,把项目上下文追加到 user message。"""
|
||||
name = _active_project(ctx)
|
||||
if not name:
|
||||
return # 未激活,普通模式,什么都不做
|
||||
|
||||
# 从尾部找最后一条 user message(不依赖 messages[1] 的位置约定)
|
||||
um = next((m for m in reversed(ctx.get('messages') or [])
|
||||
if isinstance(m, dict) and m.get('role') == 'user'), None)
|
||||
if um is None:
|
||||
return
|
||||
content = um.get('content')
|
||||
if isinstance(content, str):
|
||||
um['content'] = content + _build_injection(name)
|
||||
elif isinstance(content, list): # 多模态:追加 text block
|
||||
content.append({'type': 'text', 'text': _build_injection(name)})
|
||||
+21
-12
@@ -3,7 +3,7 @@
|
||||
# 配置: agent按SOP写好state json,通过环境变量GOAL_STATE指定路径
|
||||
import os, json, time
|
||||
|
||||
INTERVAL = 3 # check间隔短,agent跑完立刻再检查
|
||||
INTERVAL = 5 # check间隔短,agent跑完立刻再检查
|
||||
ONCE = False
|
||||
|
||||
_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -23,7 +23,7 @@ def _save(state):
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# --- prompt 模板 ---
|
||||
CONTINUATION_PROMPT = """[Goal Mode — 持续推进]
|
||||
CONTINUATION_PROMPT = """[Goal Mode — 持续优化]
|
||||
|
||||
<objective>
|
||||
{objective}
|
||||
@@ -31,16 +31,25 @@ CONTINUATION_PROMPT = """[Goal Mode — 持续推进]
|
||||
|
||||
⏱ 已用 {elapsed_min:.0f} 分钟,剩余约 {remaining_min:.0f} 分钟。第 {turn} 次唤醒。
|
||||
|
||||
你正在 Goal Mode 下工作:
|
||||
1. 禁止说"已完成,是否继续"——预算没到就不准停,不准提前交付
|
||||
2. 分析用户需要的交付物,落实成少量核心成果,后续任务都围绕核心成果的质量提升
|
||||
3. 在 cwd 下建立工作文件夹存放成果和进度,读取记忆中可能对成果质量有帮助的原则
|
||||
4. 每轮选你认为最能提升成果质量的方向,深入打磨持续修改核心成果
|
||||
如果多轮都是同类型的小修——换一个完全不同的角度重新审视,
|
||||
找到新的薄弱点再深入。包括但不限于测试/边界case/性能/安全/代码质量/美观。
|
||||
5. 换角度的方法:假装你是第一次看到这个成果的使用者/审阅者/攻击者,
|
||||
找到它最容易出问题的地方,然后修它。
|
||||
6. 要为了目标持续推进,在工作文件夹中记录进度,不要更新全局记忆。
|
||||
你正在 Goal Mode 下工作:无法宣告完成,你会被无法阻止地持续唤醒直到预算耗尽
|
||||
唤醒后流程(3选1):
|
||||
1. 创造阶段(第一次唤醒):分析objective,在cwd建工作文件夹,严格按照objective执行
|
||||
2. 检验阶段:从不同视角检验创造结果,产出检验报告
|
||||
- 换身份查看(读者/受众/用户/测试工程师/领导) | 设计未跑过的更难测例 | 查素材/事实/引用的真实性与数量/说服力 | 代码质量/产物格式/美观 | 实测验证(亲自执行/模拟用户操作)
|
||||
- 按任务类型**轮换**选用合适的角色和方法
|
||||
- 在遵循原始需求约束下追求超预期,拒绝保守和平庸,必须提出“不够出色”的点
|
||||
- 先保及格线(无事实错误/乱码/格式错误,能运行,过基础测例,遵循用户约束),及格同时追求出色
|
||||
3. 改进阶段:针对检验报告优化改进交付物,必须实质性改进
|
||||
|
||||
原则:
|
||||
1. 每次唤醒**交替**进行检验阶段和改进阶段,保留每次的检验报告和改进changelog。
|
||||
2. 除非发现严重问题,不要对创造结果进行完全重写,而是改进
|
||||
3. 严格区分交付物和进度报告,交付物中不要混入`已检验`等中间信息
|
||||
4. 若检验都是无关紧要问题,下次升级检验(要求更出色产物/更苛刻视角/更难测试/对照原始需求重审/开subagent第三方评审)
|
||||
5. 改进阶段禁止产出"无改动"。若检验未发现值得改的点,说明检验标准太低——本轮产出"检验标准升级报告",论证当前标准为何不够高并提出新标准,下轮按新标准重新检验。
|
||||
6. 在工作文件夹中记录进度,不要更新全局记忆
|
||||
7. 所有阶段都建议进行充分调研:web调研、查看记忆和相关SOP、获取用户倾向
|
||||
8. 禁止进行sha1等无用验证,文件版本不会出错
|
||||
"""
|
||||
|
||||
BUDGET_LIMIT_PROMPT = """[Goal Mode — 预算耗尽,收口]
|
||||
|
||||
Reference in new issue
Block a user