chore: import original project baseline

Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
This commit is contained in:
2026-09-17 16:40:01 +08:00
commit a7412824e0
124 changed files with 26747 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Python
__pycache__/
*.pyc
.venv/
# Runtime data: config with API keys, database, attachments, locks. Never commit.
data/
# Diagnostics / logs
*.log
+235
View File
@@ -0,0 +1,235 @@
# 项目文件架构
> 仅描述目录与文件的基础组织,不涉及具体实现细节。
```
GeekAgent-Studio/
├── main.py # 程序入口
├── requirements.txt # 依赖清单
├── untitled.ui # Qt Designer 界面文件
├── ui/ # 表现层(桌面窗口 + 本地 Web 渲染)
│ ├── views/ # PyQt6 窗口与组件逻辑
│ │ └── system_tools/ # 系统级工具(全局热键、截屏等)
│ ├── web/ # 本地 HTML/JS/CSS 渲染层
│ │ └── highlight/ # 本地代码高亮库
│ └── assets/ # 静态资源(样式表、图标)
│ └── icons/
├── core/ # 控制与大脑层(后端核心)
├── agents/ # Agent 逻辑与执行器
├── workspace/ # 智能文件系统与代码编辑
├── tools/ # 动态 Skill / Tool 生态
│ ├── builtin_tools/ # 内置基础工具
│ └── dynamic_skills/ # 动态生成的工具脚本
├── data/ # 本地数据与缓存
│ ├── attachments/ # 聊天附件
│ └── .agent_history/ # 文件修改备份
├── svg/ # 界面图标资源
└── tests/ # 测试用例
```
---
## 流式显示诊断体系(2026-07 新增,重要)
**历史教训**`dist/GeekAgent-Studio/` 是 PyInstaller 打包产物,`ui/web` 被冻结在
`_internal/ui/web/` 里——**改源码不影响 exe**。修 UI 问题后必须重新打包
`python -m PyInstaller geekagent.spec --noconfirm`),否则用户跑 exe 永远看不到修复。
**链路诊断日志**`stream_diag.log`,应用根目录,每次启动清空):
- Python 侧:`APP_START` / `FRONTEND_VER <前端版本戳>` / `CHUNK`(前 3 条 + 每 50 条
+ 会话不匹配时全记)/ `THINK` / `RESTORE` / `JS_DIAG`(回复完成时抓 JS 事件环)
- JS 侧:`window.__APP_VER` 版本戳 + `__diag` 事件环(createMessage / appendToken
采样 / tokenDOM 缓冲长度 vs DOM 长度 / NO_WRAPPER 告警 / restore / history / finish
- 读日志即可回答:token 到了吗?wrapper 在吗?缓冲有内容吗?DOM 写进去了吗?
**助手正文透明气泡**(用户方案):`.message.assistant .md-segment` 浅灰半透明底 +
圆角边框,流式中/完成后视觉一致;正文段元素在首个 token 时同步创建,气泡即时出现。
---
## Agent 核心(core/agent)— pi 1:1 移植
```
core/agent/
├── types.py # AgentMessage(kind 标记压缩摘要) / AgentConfig / 事件 / ToolCall / ToolResult
├── context.py # token 估算(usage 锚定 + CJK 感知)/ 输出钳制 / 压缩触发公式
├── stream_fn.py # openai_stream:流式 + tools 序列化 + OpenAI⇄pi 消息转换
├── tools.py # bash(shell=True) / read / write / edit + 文本兜底解析器
├── loop.py # run_loop:系统提示注入 → 流式 → 工具批执行 → 截断保护
├── agent.py # Agent:事件订阅/发布(subscriber 异常静默,pi 语义)
├── compaction.py # pi harness 压缩算法 1:1(切点/断轮/迭代摘要/文件附录)
└── recovery.py # 429/上下文溢出/截断 重试与恢复(pi 精确语义)
```
- 每次发送 → 新 `AgentWorker`core/llm_engine.pyQThread 胶水层);
多轮工具循环在一次发送内部闭环,UI 线程只收 Qt 信号。
- `stream_fn` 协议:`stream_fn(context, model, signal, max_tokens, tools=None)`
`tools` 必须序列化进 OpenAI 请求体(否则模型只能"文字扮演"工具调用)。
- `SYSTEM_PROMPT.md`(项目根):worker 模式每次请求头部注入,不入历史、不受压缩影响。
### 上下文管理与压缩(2026-07-21 升级为 pi harness 原版算法 1:1
对照 `packages/agent/src/harness/compaction/compaction.ts` + `utils.ts` 逐函数移植:
- **触发公式**`shouldCompact: tokens > contextWindow reserveTokens`
`reserveTokens=16384``AgentConfig.compaction_reserve`),替换旧 40% 启发式。
- **token 估算**usage 锚定 1:1——最后一条有效 assistant(非 aborted/error 且
totalTokens>0)的精确值 + 其后消息逐条估算;无 usage 则全部逐条。
逐条估算用 CJK 感知版(唯一已声明偏差:pi 是 chars/4)。
- **切分点** `find_cut_point`:从尾部回扫累计 token 至 `keepRecentTokens=20000`
取该位置起第一个有效切点(user/assistanttoolResult 不可切);
切点非 user → **断轮**:轮起点前的历史与轮前缀**分两次 LLM 摘要**,
拼接 `{history}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix}`
- **迭代式更新**:上次压缩摘要(`kind="compaction_summary"` 消息)不重摘,
作为 `<previous-summary>``UPDATE_SUMMARIZATION_PROMPT`pi 四套提示词逐字移植)。
- **文件操作附录**:摘要范围内 read/write/edit 工具的 path 提取,
摘要尾部追加 `<read-files>/<modified-files>`1:1 utils.ts)。
- **摘要预算**`maxTokens = min(0.8×reserve, model.maxTokens)`;轮前缀 `0.5×reserve`
- **对话序列化**`[User]:` / `[Assistant thinking]:` / `[Assistant]:` /
`[Assistant tool calls]:` / `[Tool result]:`(超 2000 字符截断)逐字一致。
- **唯一已声明偏差**:摘要 LLM 调用失败时降级为机械摘录(pi 返回错误),
桌面应用优先不丢上下文。
- 架构级差异:会话重启后 agent 内存态不持久化(DB 只存时间线),
压缩摘要随之丢失、上下文从零重建——与 pi 的 compaction 条目落盘不同(见桌面文档)。
## 时间线持久化(2026-07 新增)
agent 模式的完整事件时间线按序落库,切会话/重载后 1:1 还原:
```
messages.timeline (JSON, 可空)
[
{"t":"think", "text":"…"},
{"t":"text", "text":"…"},
{"t":"tool", "id":"call_x", "name":"bash", "args":"{…}",
"ok":true, "result":"…"} # ok:null = 仍在执行
]
```
- **累积**`MainWindow._active_streams[session]["timeline"]`
reasoning/chunk/tool 三组 Qt 信号按到达顺序累积。
- **入库**:正常结束 / 中止 两条路径都带 `timeline``db.add_message`
- **重载**`load_messages_to_web` 对带 timeline 的 assistant 行调
`renderTimelineHistory`(静态时间线:思考折叠、chip 定格、文本完整渲染)。
- **切回进行中会话**`restoreStreamingTimeline` 按流式状态恢复,
后续 token 无缝续流(续接紧邻的进行中块,否则新开一段)。
- **API 上下文重建**`build_api_context` 对带 timeline 的行重建完整链
assistant+tool_calls → tool 消息),模型跨轮次工具记忆不丢。
- 旧消息(无 timeline 列值)走原有聚合渲染,向后兼容。
## 流式渲染鲁棒性 + agent 运行期 UX(本轮新增)
### 1. 流式内容可见性 —— 不依赖 rAF / 页面定时器
根因:正文/思考内容原来只在 `requestAnimationFrame` 回调里写入 DOM
rAF 依赖合成器 BeginFrame,在 GPU 上下文丢失(本机 AMD 核显实测会
周期性 context lost)/窗口隐藏/页面被判定后台(rAF 停发 + timer
钳制 1Hz)等环境下,内容永久空白(思考块/工具 chip 是同步插入 DOM
的所以看得见)。
对策(三层,均不依赖页面帧/定时器):
1. **同步渲染通道** `syncRenderThrottled``appendToken`/`appendReasoning`
在同一 JS 任务内 30ms 节流直接写 DOMtoken 由 Python runJavaScript
送达,必被执行);
2. **恢复路径同步上屏**`renderTimelineEntries(liveMode)` 结束即
`syncRenderThrottled(msgId, 0)` —— 中途切回会话,恢复的正文/思考
不等任何帧立即显示;
3. **Qt 看门狗**`MainWindow._render_watchdog`200ms):对当前会话
活跃流 `forceRenderNow(msgId)`(幂等,尾部无变化时近零开销)。
另有:`doStreamingRender` 逐段 try/catch(解析异常 → 全量重解析);
rAF 通道保留作平滑优化 + 40ms setTimeout 兜底;收尾取消全部定时器。
回归测试:`smoke_live_guard.py` Phase 0rAF+全部页面定时器 kill →
burst 后 400ms 内容必现)+ Phase A(仅 rAF 卡死 → 40ms 兜底);
`smoke_midswitch.py`(流式中切走→切回:块顺序/正文/思考/DB 完整 +
切回后立即同步可见)。
### 2. 深度思考:默认收起 + 进行中蓝色动画
- 流式/恢复/历史三条路径的思考块一律 `open=false`
- 进行中块带 `.streaming-think`:标签蓝色呼吸 + 省略号动画(CSS
`think-breathe` / `think-dots`);
- 收尾移除动画类,标签还原「已完成深度思考」。
### 3. bash chip:耗时 / 命中超时徽章
- bash 工具结果自带 `[exit N] (X.Xs)``命令超时(>Ns)已终止`
- `_parseToolTiming` 解析后在摘要行插入 `⏱ X.Xs`(灰)与
`⏱ 超时 Ns`(红)徽章 —— 收起状态也能看到;
- 实时(toolExecutionFinished)与恢复/历史(buildToolChip)两条路径同逻辑。
### 4. 工具结果:展开必显示 + 长结果尾部预览 + 展开按钮
- 修复旧 bug`toolExecutionFinished` 原来只在 `chip.open` 时写结果,
收起状态下执行完 → 事后展开为空;现在无条件写入;
- 结果 > 4000 字:默认只显示尾部 4000 字(前缀 `…`),正文上方出现
「⬆ 展开完整输出(共 N 字)」按钮,再点收起;`chip.__fullResult` 存全文;
- 信号链路扩容:`tool_execution_finished` 携带 `text[:20000]`(原 800);
- API 回灌限制:`build_api_context` 重建 tool 消息时截断 4000 字,
防止长输出撑爆模型上下文。
---
## 流式正文不显示:深度调试与根因(2026-07)
### 现象链
旧版(逐 token 全量 innerHTML 同步重绘)正文可见但乱序 → 时间线重构后(缓冲+rAF 增量渲染)正文流式期不显示,结束后/切回后可见。
### 诊断数据(用户环境 stream_diag.log,前端 v2
- `FRONTEND_VER 20260721-v2` ✓ 最新前端在跑
- CHUNK/THINK 逐 token 到达 UI 线程,会话匹配 ✓
- JS_DIAG 完成时刻环形缓冲 = 思考事件以 ~95/s 流入 → **JS 线程活着,token 在被处理**
- 结论:不是 JS 没跑,是**屏幕没有把 DOM 变化画出来**
### 根因(本机复现)
AMD 核显 GPU 上下文周期性丢失:
-`--enable-gpu-rasterization --ignore-gpu-blocklist`(旧默认)离屏实测:
连续 `SharedImageStub: context already lost` 错误 → **页面 JS 处理/Qt 定时器全部停摆**
-`--disable-gpu` 后同样代码 100% 正常(真实消息内容回放探针 b/d 双满)
- 用户屏幕上的部分停摆:DOM 已写入,但 GPU 光栅化产物无法合成上屏 → 正文不可见
### 修复
1. **main.py 默认改为 `--disable-gpu`**(全 CPU 软渲染,彻底绕开 GPU 上下文丢失;
旧 GPU 模式保留为 `HAOCODE_RENDER=gpu`
2. 保留双保险渲染通道(同步节流渲染 + 200ms Qt 看门狗 forceRenderNow
3. v3 探针:`probeStream()` 每 2s 写入 `PROBE` 日志(缓冲长度/DOM 长度/offsetHeight/opacity/display),
`dumpDiag` 改为按类型摘要 —— 若再异常可一次性定位断点
### 最终根因(2026-07-21 v7,推翻上述 GPU 主因结论)
人肉 debugtoken 级日志 + 屏幕截图 + 逐层盒模型探针 + 页面内对照/克隆实验)锁定:
**Chromium 布局失效 bug:空 `.md-segment` 先入文档 → 匹配 `:empty{display:none}`
→ 30ms 后写入内容 → 引擎未重新触发布局 → 盒永久 0x0(正文不可见)。**
证据链:
1. token 日志:正文 `段buf``dom`textContent)同步增长(71c 全在 DOM),但 `h=0`
2. 像素分析 `diag_shot_*.png`:完成时刻屏幕上也无正文(切会话才可见 = DB 重渲路径)
3. 逐层探针:seg 的祖先链全部存活(reply-content w=119/h=75),唯 seg 子树 w=0/h=0
4. 对照实验:同容器新建的 `.md-segment`(带内容)h=24 正常;原始流式段 h=0
5. 克隆实验:原节点的**完整克隆 h=24**;把原节点**重新 appendChild 一次即恢复 h=24**
—— 纯布局状态腐坏,与 CSS 规则无关
为什么切会话重渲正常:`renderTimelineHistory` 创建节点时**先填充 innerHTML 再插入**
从不以空节点进文档,`:empty` 从未匹配。
### v7 修复(真正根因)
1. **appendToken / renderTimelineEntries(live)**:新正文段先 `mdStateOf + renderMarkdownStreaming`
同步渲染、**带内容再 insertBefore**(永不空节点入文档)
2. `.md-stable/.md-tail``display:contents``block`(规避 contents 布局风险,加内层边距补偿)
3. 周期性流式截图改为 `HAOCODE_SHOT=1` 可选 —— 实测 `QWidget.grab()` 强制出帧会
阻塞渲染器主线程 1-2s,会干扰正文 token 处理(诊断干扰项)
4. 保留 `--disable-gpu` 默认(对离屏测试环境仍有必要)与双通道渲染 + 看门狗
5. 新增 `tests/verify_onscreen.py`:**真实窗口真实布局**验证(offscreen 无布局,
此类 bug 离屏测不出来;本 bug 即由此漏检 3 轮)
6. v6 起 JS 端带渲染器主线程心跳(dt>1.5s 告警)+ finish 后正文段体检日志
+288
View File
@@ -0,0 +1,288 @@
# Frame —— haocode 逐目录 / 逐文件说明
> 本文件是 `readme.md` 的下钻版:**每个文件夹、每个文件到底负责什么**,
> 以及关键的类 / 函数名(方便直接搜索定位)。
> 阅读建议:先看 `readme.md` 的架构总览,再回来看这里的目录细节。
---
## 0. 一眼看清分层
| 层 | 目录 | 职责 | 能否单独跑 |
|---|---|---|---|
| 入口 | `main.py` | 环境准备 → QApplication → MainWindow | — |
| 表现层 | `ui/views/` | PyQt6 窗口、控件、事件编排 | 需 GUI |
| 渲染层 | `ui/web/` | HTML/JS/CSS(离线三库:marked / dompurify / KaTeX + highlight.js | 浏览器可单独开 |
| 引擎层 | `core/llm_engine.py` | 把 agent 核心包成 Qt 线程,发信号给 UI | 需 PyQt6 |
| Agent 核心 | `core/agent/` | 与 pi 1:1 的循环 / 流式 / 重试 / 压缩 / 工具 | ✅ 纯 Python 可测 |
| 存储层 | `core/db_manager.py` | SQLite:会话树、消息、压缩标记、附件 | ✅ 纯 Python 可测 |
| 外部集成 | `core/webview2.py``vendor/webview2/` | WebView2 原生子窗口 + .NET SDK | 需 Windows |
| 资源 | `svg/``ui/web/katex|highlight``data/` | 图标 / 前端库 / 配置 | — |
---
## 1. 根目录文件
| 文件 | 说明 |
|---|---|
| `main.py` (111 行) | **程序入口**。① 顶部先做 stdout/stderr UTF-8 保护(打包成 exe 后 GBK 环境遇 emoji 会崩,见 readme 第六节);② 设置 `QTWEBENGINE_CHROMIUM_FLAGS`(默认 `--disable-gpu` 全 CPU 软渲染,规避 AMD 核显 GPU 上下文丢失导致"DOM 变了但屏幕不刷新");③ `QApplication` + 字体的 DPI/pointSize 防御;④ `MainWindow()` + `show()` + `app.exec()`。 |
| `SYSTEM_PROMPT.md` (~3400 tokens) | **agent 系统提示词**。0 身份 / 1 运行环境(含 §1.1 cmd.exe 陷阱表)/ 2 可用工具 / 3 工具参数与执行语义(read、bash、write、edit 四条铁律、执行顺序与并发)/ 4 调用方式 / 5 失败与异常处理(错误串→动作对照表)/ 6 工作方式 / 7 安全红线 / 8 会话与上下文。**每次请求由 `load_system_prompt()` 重新读取,改完不用重启**;只在 `enable_tools=True`(worker 模式)时注入,且**不进历史、不参与压缩**。 |
| `requirements.txt` | 依赖清单(只列代码真实 import 的包,AST 扫描核对过)。 |
| `haocode.spec` | **PyInstaller 打包配置**onedir)。关键点:把 `ui/web``svg``vendor/webview2``SYSTEM_PROMPT.md``data/config.json``WebView2Loader.dll` 收集进 `_internal/`(因为代码里普遍用 `dirname(__file__)` 上溯定位项目根,冻结后根 = `_internal`);**刻意不含** `data/chat_history.db``data/attachments/``console=False`;带 `runtime_hooks`。 |
| `pyi_rth_trace.py` | 打包**运行时钩子**:把冻结版 stdout/stderr 重定向到 exe 同级 `haocode.log`(GUI 版没有控制台,没有它启动异常会完全静默)。 |
| `WebView2Loader.dll` | WebView2 加载器,供"以工作目录为基准"的兜底加载路径使用(首选路径是 `vendor/webview2/webview2loader_x64.dll`)。 |
| `ARCHITECTURE.md` / `readme_our.md` / `黑边两现象分析报告.md` | **历史文档**(早期架构、早期设计稿、窗口缩放黑边问题分析)。其中提到的 `agents/``workspace/``ui/assets/``untitled.ui` 等目录/文件**已在交接版移除**,最新结构以 `readme.md` + 本文件为准。 |
| `_out.txt` / `_t0.txt` | 文本示例文件(按交接要求保留)。 |
---
## 2. `core/` —— 后端核心
### 2.1 `core/db_manager.py` (715 行) —— SQLite 存储层
**表结构**
```sql
sessions(id, title, created_at, updated_at, has_messages, sort_order, is_starred,
current_leaf_msg_id, mode) -- mode: 'chat' | 'worker' | NULL(未发过消息)
messages(id, session_id, role, content, reasoning, is_ignored, created_at,
attachment_metadata, parent_id, timeline, usage,
stop_reason, error_message)
```
- **会话是链表树**`messages.parent_id` 指向上一条,`sessions.current_leaf_msg_id` 记录当前叶子。
`get_message_chain()` 从叶子向上回溯再反转 → 得到"当前激活时间线"(绕开所有废弃分支)。
- `role` 取值:`user` / `assistant` / `system` / **`compaction`**(压缩标记行,`content` 是摘要)。
- `timeline`assistant 行的**事件时间线 JSON**`{"t":"think"|"text"|"tool", ...}`),
工具条目形如 `{"t":"tool","id","name","args","ok","result"}``ok=None` 表示开始了但没拿到结果。
- `usage`:本轮精确 token 用量(`{"input","output","cacheRead",...}`),供显示与压缩估算做**锚点**。
- `stop_reason` / `error_message`:失败轮次标记(`'error'`),NULL = 正常行。
**关键方法**
| 方法 | 作用 |
|---|---|
| `add_message(...)` | 插一条消息并把会话叶子前推(`stop_reason`/`error_message` 为可选新参数) |
| `get_message_chain(session_id)` | 取当前激活链(含环检测保护) |
| `get_branch_info(parent_id)` | 某父节点下的所有子分支(UI 的 `1/3` 分支切换) |
| `update_session_leaf(session_id, leaf)` | 手动改叶子(分支切换、错误回退用) |
| `insert_compaction_mark(session_id, summary, cut_before_id, first_retained_id, meta)` | 插压缩标记行 |
| `copy_session(...)` | 整会话深拷贝:消息 id 重映射、parent 重连、**附件物理复制改名**、标题 `(副本 N)` 递增 |
| `_upgrade_schema(cursor)` | 旧库自动补列。⚠️ **注意**:旧的 `upgraded=True` 分支会触发"链表化重构"(把树按时间拍平成线性链)→ 新增列**绝不能**置这个标志(源码里有警告注释) |
| 冻结态库路径(文件头) | `sys.frozen` 时优先 `exe/../../data/chat_history.db`(存在才共用),否则 `exe 同级/data/chat_history.db` |
### 2.2 `core/llm_engine.py` (446 行) —— 引擎层(Qt 线程桥)
| 名字 | 说明 |
|---|---|
| `load_system_prompt()` / `_load_config()` / `_provider_info()` | 读 `SYSTEM_PROMPT.md``data/config.json`(缺失返回兜底短提示词 / 空配置) |
| `AgentWorker(QThread)` | **worker 模式**:组装 `AgentConfig`model / system_prompt / tools / retry=3 次 2s×2)→ `agent.set_stream_fn(openai_stream)``AgentRunner(agent, summarize_fn=..., 回调...)``runner.run(last_user)`。信号:`chunk_received` / `reasoning_received` / `tool_execution_started|updated|timed|finished` / `context_compacted` / `compaction_started` / `retry_scheduled` / `retry_finished` / `usage_updated` / `error_occurred` / `finished` |
| `_bridge(event)` | `AgentEvent` → Qt 信号(在工作线程内 emit,跨线程 queued 投递) |
| `_make_summarize_fn(model)` | 压缩用的**非流式** LLM 调用(`prompt_text, system_prompt, max_tokens → str` |
| `ChatWorker(QThread)` | **chat 模式**:单次流式,无工具、无重试、无压缩(快速问答) |
| `TitleWorker(QThread)` | 会话自动命名 |
| `abort()` / `cancel()` | 中断:置 `AbortSignal` → agent 循环在 chunk 边界收尾为 `stop_reason="aborted"` |
### 2.3 `core/webview2.py` (406 行) —— WebView2 集成
| 名字 | 说明 |
|---|---|
| `get_environment(app)` | 返回 WebView2 环境或 `None`None → UI 侧回落 QtWebEngine)。**两道守卫**:① `_wv2_allowed_here()` 无头/测试环境直接不启用;② `acquire_instance_lock()` 抢不到单实例锁(已有实例在跑)就跳过,**并且跳过 `taskkill`**(否则会把正在运行的兄弟实例的浏览器进程杀掉 → 它的 controller 变 disposed → **DOM 正常但视觉层永久空白**,这就是历史 T0 事故) |
| `acquire_instance_lock()` | `msvcrt.locking``data/app_instance.lock``HAOCODE_INSTANCE_LOCK_FILE` 可覆盖路径,供测试隔离);返回 True=唯一实例 / False=已有实例 / None=平台不支持 |
| `Wv2Session` | 一个 WebView2 实例:创建 controller、找子窗口 hwnd`find_child`)、`set_bounds`/`set_visible`/`navigate`/`execute_js`/`close` |
| `_pump_wait(op, app, timeout)` | 等待异步 COM 操作时泵 Qt 事件(避免 UI 卡死) |
| 环境变量 | `HAOCODE_FORCE_QTWEBENGINE=1` 强制回落;`QT_QPA_PLATFORM=offscreen` 自动不启用 WebView2 |
### 2.4 `core/debug_log.py` (78 行) —— 调试事件总线
`debug_log(msg, tag)` 写内存环形缓冲 + 落盘;`poll_debug_cmd()` 取调试窗口输入的命令;
`autostart_debug_window(cfg)``config.json: debug_window_autostart` 决定是否开调试窗口。
### 2.5 `core/agent/` —— pi 1:1 agent 核心(9 文件)
| 文件 | 行数 | 关键名字 | 说明 |
|---|---|---|---|
| `types.py` | 331 | `AgentMessage` `ToolCall` `AgentConfig` `ModelConfig` `RetryConfig` `AgentEvent` `AbortSignal` `new_id` | 全部数据结构(dataclass)。`AgentMessage``content/reasoning/tool_calls/stop_reason/error_message/usage/timestamp` |
| `agent.py` | 204 | `Agent``subscribe`/`prompt`/`continue_`/`steer`/`follow_up`/`abort`/`set_stream_fn`) | agent 对象:状态机 + 事件订阅 + 输入队列(steering=轮中插话,followUp=队列尾续跑) |
| `loop.py` | 482 | `run_loop` `_stream_turn` `execute_tool_calls` `_execute_parallel` `_execute_sequential` `_should_terminate_batch` | **主循环**:注入 system prompt → 输出预算钳制 → 流式一轮 → 工具批(含 length 截断保护:参数可能残缺则一律不执行)→ 轮末钩子(`prepare_next_turn` / `should_stop_after_turn`)→ 停止判定 |
| `stream_fn.py` | 438 | `openai_stream` `to_openai_messages` `from_openai_messages` `classify_error` `_parse_tool_call` `_pick_reasoning` `_pick_usage` | OpenAI 兼容流式调用(180s 超时、SDK 层 `max_retries=0`——重试统一交给 recovery 层)+ 异常分类(rate_limit/timeout/connection/server_error/overload/auth/overflow+ 工具调用增量拼装(JSON 解析失败兜底 `{}` + 保留 raw |
| `recovery.py` | 519 | `AgentRunner``run` `pre_prompt_compaction` `compact_if_needed` `_handle_post_agent_run` `_prepare_retry` `_remove_last_bad_assistant` `_do_compaction`)、`is_context_overflow` `is_retryable_assistant_error` `is_recoverable_length` `compute_retry_delay_ms` `compact_diag_log` | **三路决策**:① 上下文溢出 → 压缩恢复(只试一次)② 可重试错误(429/5xx/超时/断连…且非配额耗尽)→ 移除坏消息 + 退避(2s→4s→8s,最多 3 次)③ 否则停。另含**轮中主动压缩** `compact_if_needed`(单条巨型工具输出不再依赖"失败一次"再兜底;同 run 连败 2 次即止损) |
| `compaction.py` | 637 | `compact_context` `should_compact` `CompactionSettings` `find_cut_point(s)` `serialize_conversation` `FileOperations` | 上下文压缩:按 pi harness 原版算法找**有效切点**(不切开工具调用对)→ 摘要化前半段 → 保留尾巴 → 产出切点 id(供 UI 落库成 `compaction` 标记行) |
| `context.py` | 341 | `estimate_context_tokens` `estimate_message_tokens` `calculate_context_tokens` `_find_last_usage` `clamp_max_tokens_to_context` `clamp_outputs_to_context` | token 计量与预算钳制:优先用**最近一次真实 usage 做锚点**,无锚点才按字符估算(含图片按比例计) |
| `tools.py` | 873 | `tool_read` `tool_bash` `tool_write` `tool_edit` `default_tools` `prepare_tool_call` `parse_text_tool_calls` | 四个工具的实现与参数校验。`tool_bash` 用「Popen + 读线程 + 队列」实现**实时输出**(`on_update`)、每秒读秒(`on_timer`)、超时 `taskkill /F /T` 杀进程树、50KB 截断;`tool_edit` 按「原文件快照定位 + 唯一匹配 + 非重叠 + 全有或全无」改文件 |
---
## 3. `ui/` —— 表现层
### 3.1 `ui/views/main_window.py` (6019 行) —— 主窗口(全项目核心)
单文件承载了绝大部分 UI 与编排逻辑,按区块读:
| 区块(搜关键字) | 说明 |
|---|---|
| `MainWindow.__init__` | 组装:DBManager、左侧栏、聊天区、工具栏、输入区、右侧任务面板、调试窗口 autostart、全局热键 |
| `setup_ui()` | 三栏布局 `main_layout = [sidebar | chat_area | bash_panel]`;顶部工具栏(`历史` 按钮) |
| `setup_stylesheet()` | 全量 QSS`#sidebar` / `#right_sidebar` / `#bl_*` / `#top_tool_btn` / 气泡…) |
| `get_svg_path()` / 侧边栏折叠动画 | `sidebarWidth` 属性 + `QPropertyAnimation`;窗口过窄时自动折叠 |
| 输入区与附件 | `_on_image_pasted` / `_on_files_dropped` / `_on_long_text_pasted` / `AttachmentPreviewOverlay`(独立顶层窗口,避免被 WebView 遮挡) |
| `send_message()` | ① 若本会话在生成 → 走**中断**分支;② 建 `_active_streams[session_id]` 流状态;③ 落库用户消息;④ 起 `AgentWorker`/`ChatWorker` 并接线全部信号 |
| `_active_streams` | 每会话流式状态:`msg_id / parent_id / previous_leaf_id / content / reasoning / timeline / tl_kind / usage / worker` |
| `on_chunk_received` / `on_reasoning_received` | 累积正文/思考 + 维护时间线(`t:text` / `t:think` 分段)+ 定时刷新上下文标签 |
| `_on_tool_started/updated/timed/finished` | 工具事件 → 时间线条目 + 前端 chip + 右侧任务面板转发 |
| `_on_context_compacted` | 压缩完成 → 同一气泡原地定格(摘要全文)→ **插 `compaction` 标记行入库** |
| `on_reply_finished` | 正常收尾:三维全空则不入库;否则落库 assistant 行(正文/思考/时间线/usage),叶子前推 |
| `on_error``_persist_failed_stream` | **失败轮次也入库**(对照 pi`message_end` 无条件持久化)——本轮已完成的工具结果/正文/思考全部保留,尾部追加 `> ⚠️ [本轮中断] …`(并额外作为一条 timeline 文本条目,因为带 timeline 的行回放不读 content);完全空的一轮才 `is_ignored=1` 只留痕不回放 |
| `_persist_interrupted_stream` | 用户主动中断:有内容则入库,全空则回退叶子 |
| `build_api_context(session_id)` | **DB → API 上下文**:处理压缩标记(标记之前只发摘要)、附件转 base64(图片走视觉接口)、回放 assistant 的 timeline(重建 `tool_calls` + `tool` 结果,结果截断 4000 字)、跳过 `is_ignored` 与全空行、孤儿工具补合成结果、错误行照常回放 |
| `load_messages_to_web(session_id)` | 切会话:清空前端 → 按链渲染历史(含时间线回放)→ 刷新右侧面板 |
| 模式切换 | `mode_switch` 配置 + `chat`/`worker` 两种 worker`sessions.mode` 锁定已发过消息的会话模式 |
| 右侧面板接线 | `bash_panel.set_session(...)` + 四个 tool 事件转发(仅当前会话) |
| 关闭清理 | `closeEvent`:中断所有 worker、持久化进行中的回复、清理后台任务 |
### 3.2 `ui/views/bash_panel.py` (944 行) —— 右侧任务面板
| 名字 | 说明 |
|---|---|
| `BashPanel(QWidget)` | 外壳:宽 260 / 收起 52(`panelWidth` 属性 + 260ms InOutCubic 动画);**开关按钮在栏内**(收起态=栏正中 34×34;展开态=标题行右上角 28×28,两按钮分居 QStackedWidget 两页,任何时刻只有一个可见) |
| `_ResizeHandle` | 左边缘 4px 拖拽调宽(最小 200px),松手把宽度写入 `config.json: bash_panel_width`,下次展开自动恢复 |
| `_Section` | 一栏 = 头部行(24px **硬固定**+ `body`(滚动区 + 提示语);折叠只收 `body` 的高度 |
| `_layout_targets()` | 两栏折叠/展开的统一布局策略:**层从顶部堆叠,余量只由"展开着的已完成"吸收;已完成收起时余量进 `spacer` 空白占位**(避免头部被顶到面板底部) |
| `BashLayer` | 一层 = 一次 bash 执行:头部(状态点/名称/耗时/状态标签/命令预览/箭头)+ 展开后「参数」「输出」两块;运行中实时输出(200KB 上限,超出标注)、完成后显示**进入上下文的原文**、压缩切点之前的层标「已出上下文」 |
| 常量 | `PANEL_W_DEFAULT/MIN/MAX``FOLD_MS=200``FOLD_STEP=16``LAYER_LIMIT=30``LIVE_BUF_CAP=200KB``_SPLIT_ORIENTATION`Vertical,改 Horizontal 即左右并排) |
### 3.3 `ui/views/` 其余文件
| 文件 | 行数 | 说明 |
|---|---|---|
| `chat_bridge.py` | 188 | `ChatBridge(QObject)`**Python → JS 单向桥**QWebChannel)。每个方法 = 一个前端函数:`create_message` / `append_token` / `append_reasoning` / `finish_message` / `tool_execution_started|updated|timed|finished` / `restore_streaming_timeline` / `render_timeline_history` / `show_note` / `show_error` / `compaction_started` / `compaction_finished`。用 `json.dumps` 转义全部文本,杜绝注入 |
| `wv2_view.py` | 217 | `WebView2View(QWidget)`:把 WebView2 原生子窗口包成"看起来像 QWebEngineView"的控件——提供 `page()``_PageShim.runJavaScript`)、`setUrl``grab``attach_bridge``sync_bounds`(父窗口移动/缩放时同步原生子窗口位置),从而让上层渲染代码**两条渲染路径共用一套调用** |
| `custom_web_page.py` | 65 | `CustomWebPage(QWebEnginePage)`QtWebEngine 回落路径,`acceptNavigationRequest` 限制只允许本地 file:// |
| `debug_window.py` | 214 | `DebugWindow(QWidget)`:两个页签「对话消息 / 应用日志」+ 命令输入框;`_TailReader` 增量读日志文件 |
### 3.4 `ui/views/system_tools/`
| 文件 | 说明 |
|---|---|
| `file_reader.py` | `read_text_file(path, max_bytes)`:文本/代码文件读取;带**二进制黑名单**Word/Excel/PPT/压缩包/可执行/媒体),PDF 不在黑名单里(交给 `tools/builtin_tools/pdf_reader.py` |
| `global_hotkey.py` | `GlobalHotkeyThread(QThread)``RegisterHotKey` 注册全局热键(默认 Ctrl+Shift+S 截屏),失败只打印不影响主流程 |
| `screen_capture.py` | `ScreenCaptureOverlay(QWidget)`:全屏遮罩框选区域 → 截屏 → 转成图片附件(`screenshot_captured` 信号) |
### 3.5 `ui/web/` —— 本地渲染层(完全离线)
| 文件 | 行数 | 说明 |
|---|---|---|
| `index.html` | 82 | 页面骨架 + 引入本地 `marked` / `dompurify` / `highlight` / `katex`(无任何 CDN |
| `app.js` | 2111 | **前端全部逻辑**`createMessage` / `appendToken`(稳定前缀增量渲染 + rAF 批量)/ `updateMessage` / `finishMessage` / 时间线渲染(思考块、工具 chip、压缩气泡)/ `marked.parse` 统一拦截点(先抽公式占位符再渲染,最后 KaTeX 回填)/ 自动滚动与"贴底"判定 / 分支切换 `1/3` |
| `style.css` | 902 | 全部样式:气泡(assistant 85% 宽、透明正文)、思考块、工具 chip、紧凑模式、公式块、错误提示条 |
| `marked.min.js` / `dompurify.min.js` | — | Markdown 渲染 + HTML 消毒 |
| `highlight/` | — | highlight.js + atom-one-dark 主题 |
| `katex/` | — | KaTeX 0.16.11js + css + woff2 字体) |
---
## 4. `tools/`
| 文件 | 说明 |
|---|---|
| `builtin_tools/pdf_reader.py` | **PDF 专用解析**`extract_pdf_text` / `extract_pdf_images`),基于 PyMuPDF。被 `ui/views/main_window.py` 直接 import(文本模式抽文字、图片模式抽内嵌图片为 PNG 再送视觉接口),并有专门的 `PDFExtractWorker` 线程避免大文件卡 UI |
| `__init__.py` / `builtin_tools/__init__.py` | 包结构标记(**必须保留**,主窗口按 `tools.builtin_tools.pdf_reader` 路径 import |
> 交接版已删除该目录下**全空且零引用**的 `registry.py` / `conda_env.py` / `file_ops.py` / `web_search.py`。
---
## 5. `svg/` —— 图标(18 个)
`panel.svg`(左侧栏开关)/ `panel_right.svg`(右侧任务面板开关)/ `chevron_down|right.svg`(弹窗箭头)/
`mode_chat.svg` / `mode_worker.svg`(模式)/ `model.svg` / `provider*.svg`(模型弹窗)/ `send*.svg` / `stop*.svg` /
`upload.svg` / `cross.svg` / `check.svg` / `main.svg`。全部经 `MainWindow.get_svg_path()` 按项目根解析。
---
## 6. `data/` —— 运行时数据
| 文件 | 说明 |
|---|---|
| `config.json` | 供应商/模型/开关(**交接版已清空 api_key**)。关键键:`providers``default_provider/model``temperature``debug_window_autostart``mode_switch``webview_backend`,运行中还会被写入 `bash_panel_width`(面板宽度记忆) |
| `chat_history.db` | 聊天主库(**交接版不含**,首次运行自动创建空库 + 初始对话) |
| `attachments/` | 附件目录(图片/PDF,**交接版不含**;运行时按 `<项目根>/data/attachments/` 存放) |
---
## 7. `vendor/webview2/`
WebView2 的 .NET SDK(随仓库提供,不走 pip):
`net462_Microsoft.Web.WebView2.Core.dll``clr.AddReference` 加载)、`webview2loader_x64.dll`loader)、`sdk.nupkg`(原始包)。
---
## 8. `tests/` —— 测试与调试工具
### 8.1 正式测试套件(交接后应保持全绿)
| 文件 | 内容 |
|---|---|
| `run_tests.py` | **离线 harness**(自带用例收集 + pytest stub),跑 `test_agent_core.py` |
| `test_agent_core.py` | agent 核心:流式增量、工具批、重试、压缩、读秒、超时杀树 |
| `test_tool_params.py` | 四个工具的参数校验与错误串(35 项) |
| `test_compaction_persist.py` | 压缩标记入库 + 上下文截断行为 |
| `test_copy_session.py` | 会话复制(消息/分支/附件深拷贝/标题递增/叶子)(54 项) |
| `test_bash_stream.py` | bash 实时输出、每秒读秒、超时杀进程树、50KB 截断、非零退出 |
| `test_error_persist.py` | **失败轮次入库 + 回放取舍**(T1–T9,含旧库迁移不得破坏分支的安全用例) |
| `test_wv2_guard.py` | WebView2 双守卫(无头环境 / 跨进程单实例锁) |
| `test_debug_window.py` | 调试窗口事件与日志 |
| `test_think_code_neutral.py` | 思考块/代码块内容中性化 |
| `test_file_attach.py` / `test_pdf_reader.py` | 附件类型判定 / PDF 文本与图片解析 |
| `test_math_extract.js` | 前端公式提取逻辑(Node 运行,39 项) |
| `smoke_offscreen.py` | 主窗口离屏冒烟(8 项) |
| `smoke_mode.py` | chat ↔ worker 模式切换(16 项) |
| `smoke_copy_session.py` | 复制会话 UI 全链路 |
| `smoke_bash_panel.py` | 右侧任务面板全链路(含折叠动画/布局策略/拖拽调宽,116 项) |
| `check_db_migration.py` | 只读工具:校验库迁移后旧数据 100% 原样(消息数/父指针/叶子/分支点逐项比对) |
### 8.2 真机 / 真 API 工具(按需使用,不属于回归)
`smoke_live_guard.py``smoke_midswitch.py``smoke_persist.py``smoke_probe.py``smoke_repro_real.py`
`smoke_timeline.py``smoke_manual.py``diag_live_agent.py``diag_live_onscreen.py``diag_live_text.py`
`verify_onscreen.py``verify_math_render.py``inject_math_demo.py``debug_inject.py`
`tune_mode_popup.py``tune_model_popup.py`(两个弹窗调参工具)。
### 8.3 写测试的两条铁律
1. 不得污染真实库:测试开始时把 `core.db_manager._DEFAULT_DB` 指向临时文件(**必须在 import MainWindow 之前**
2. 不得污染真实配置:把 `HAOCODE_CONFIG_FILE` 指向临时文件(会写 `config.json` 的功能)
> 交接版已清除 81 个开发期临时物(`_tmp*` 脚本 / `_tmp_resize_vis/` 截图 / `_tmp_*.log`)。
---
## 9. 一次提问的完整生命周期(把上面所有文件串起来)
```
① 用户输入(ui/views/main_window.py: send_message
└─ 落库 user 行(core/db_manager.add_message)→ 建 _active_streams[sid] 流状态
② 组装上下文(main_window.build_api_context
└─ 取当前激活链 → 压缩标记截断 → 附件转 base64 → timeline 回放成 tool_calls/tool 消息
③ 起引擎(core/llm_engine.AgentWorker.run
└─ AgentConfig(model, system_prompt=SYSTEM_PROMPT.md, tools=default_tools(), retry=3×2s)
+ AgentRunner(summarize_fn=..., on_retry_* / on_compaction_* 回调)
④ agent 核心(core/agent/loop.run_loop
├─ 轮首/轮中压缩检查(recovery.compact_if_needed → compaction.compact_context
├─ 流式一轮(stream_fn.openai_streamtext/reasoning/toolcall 增量 + usage + stop_reason
├─ 出错 → recovery._handle_post_agent_run:溢出→压缩重试 / 可重试→退避重试 / 否则停
└─ 有 tool_calls → execute_tool_callstools.py 的四个工具,并行/串行按批次规则)
⑤ 事件回流 UIllm_engine._bridge → Qt 信号 → main_window 的 on_* 处理器)
└─ chat_bridge 调前端 app.js:增量渲染 / 时间线 chip / 思考块 / 读秒 / 压缩气泡
└─ 同时转发给 bash_panel(右侧任务面板实时更新)
⑥ 收尾落库(main_window.on_reply_finished 或 on_error→_persist_failed_stream
└─ assistant 行 = 正文 + 思考 + timeline(工具) + usage(+stop_reason),叶子前推
└─ 压缩发生 → 另插 role='compaction' 标记行
⑦ 下一次提问回到 ②,此时模型能看到上一轮的正文、工具调用与结果(失败轮次亦然)
```
+175
View File
@@ -0,0 +1,175 @@
# haocode Agent 系统提示词
## 0. 身份
你是 **haocode**(worker 模式)的本地智能体,运行在用户本机的 PyQt6 桌面应用内,
通过内嵌浏览器界面与用户对话。你具备代码理解与修改能力,并能调用工具直接操作
本机文件与 shell。你的目标是把用户交代的任务**真正做完**(改文件、跑命令、验证结果),
而不是只给出建议或描述该怎么做。
- 回答语言:**简体中文**;代码、命令、路径、标识符保持原文。
- 你在一个多轮循环里工作:可以连续调用工具、观察结果、再决定下一步,
直到任务完成或确实需要用户决策为止。
## 1. 运行环境
- 操作系统:Windows。文件路径形如 `C:\Users\14890\Desktop\haocode`
- 当前工作目录:**haocode 项目根目录**。所有相对路径都相对它解析;
每条 bash 命令都以它作为工作目录启动。
- Pythonconda 环境 `haocode`Python 3.10PyQt6、openai 已装),直接用 `python`
- 前端是本地网页(`ui/web/`),改动前端文件后需重启应用才生效。
### 1.1 shell 真相(重要:直接决定命令能不能跑对)
命令经 **cmd.exe** 执行(不是 git-bash)。但 `C:\Program Files\Git\usr\bin` 在 PATH 上,
所以 `ls` `grep` `cat` `head` `tail` `wc` `rm` `sed` `awk` 都能直接用,
管道 `|`、重定向 `>` `2>&1``&&` 也都可用。
⚠️ 下列写法会**静默出错**或报错,务必按右列的写法:
| ❌ 不要写 | ✅ 改成 | 原因 |
|---|---|---|
| `echo a; echo b` | `echo a && echo b`(或分两行写) | cmd 不认 `;`,会把 `; echo b` 当参数原样输出 |
| `echo $HOME` | `echo %USERPROFILE%` | cmd 用 `%VAR%``$VAR` 不会被展开 |
| `for i in 1 2 3; do ...; done` | `bash -c "for i in 1 2 3; do ...; done"` | bash 语法必须显式调用 bash |
| 单独一条 `cd core` | `cd core && <命令>` | **每次调用都是新进程,cd 不会跨调用保留** |
| `echo 'x'` | `echo x` | cmd 内建命令不剥单引号(`grep 'x'` 等 msys 程序会正常剥) |
多条命令用**换行**分隔最稳(实测可用)。需要 `$(...)`、单引号、`[ ]` 测试等真实
bash 语义时,一律写成 `bash -c "..."`
## 2. 可用工具
| 工具 | 用途 | 关键约束 |
|---|---|---|
| `read` | 读取**文本**文件(带行号) | 单次 ≤2000 行 / 50KB;大文件用 `offset`/`limit` 分页;**不要用于图片或二进制** |
| `bash` | 执行 shell 命令 | 经 cmd.exe;默认 120 秒超时(上限 600);输出 50KB 截断;**同批有它则整批串行** |
| `write` | 新建或**完整覆盖**文件 | 自动创建父目录;原子写入;只用于新建或整体重写 |
| `edit` | 精确文本替换 | `oldText` 必须与**原文件**逐字符一致且唯一;各条区间不得重叠;**整批全有或全无** |
看文件优先用 `read`(不要用 `cat`);探查目录、搜索内容用 `bash``ls``grep`)。
## 3. 工具参数与执行语义
### 3.1 read
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `path` | string | ✅ | — | 文件路径(相对路径基于项目根) |
| `offset` | integer | | 1 | 起始行号,**从 1 开始** |
| `limit` | integer | | 2000 | 最多读取行数,**上限 2000**(传更大按 2000 |
输出格式:每行 `%6d\t原文`,即「6 位右对齐行号 + TAB + 原始内容」,末尾附
`[已显示 a–b 行,共 N 行](还有 M 行未显示,用 offset=X 继续)`
- 只读文本(UTF-8,非法字节被替换为 )。**读图片或二进制只会得到乱码,不会报错**。
- 文件可能超过 2000 行时:先用小 `limit` 看结构,再用 `offset` 分页读到目标位置。
- ⚠️ **把读到的内容用作 `edit` 的 `oldText` 时,必须去掉行号与 TAB 前缀**,只保留文件原文。
### 3.2 bash
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `command` | string | ✅ | — | 要执行的命令(cmd.exe 语法,可用 Git 的 unix 工具) |
| `timeout` | number | | 120 | 超时秒数,**上限 600**(传更大按 600 |
- 返回值:`$ 命令` + stdout + `[stderr]` + `[exit N] (耗时)`;**退出码非 0 时结果视为失败**。
- 超时:到期会**杀掉整棵进程树**并返回 `命令超时(>Ns)已终止`
长任务(全量测试、构建、下载)请显式传 `timeout`;短查询不必传。
- 输出超过 50KB 会被截断 → 用 `-n` / `head` / `findstr` 或更精确的命令收窄输出后再逐步放宽。
- 需要等待的场景直接跑命令并设好 `timeout`,不要用反复 `sleep` 试探。
### 3.3 write
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `path` | string | ✅ | 目标文件路径;父目录自动创建 |
| `content` | string | ✅ | 完整文件内容(覆盖写,**不是追加**) |
- 只用于**新建文件**或**整体重写**;局部改动一律用 `edit`(避免丢掉未读到的内容)。
- 写入是原子的(临时文件 + 替换),不会留下半截文件。
- 覆盖已存在文件前,先确认你确实要丢弃它原有的其余内容。
### 3.4 edit
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `path` | string | ✅ | 目标文件路径 |
| `edits` | array | ✅ | 替换列表,每项 `{oldText, newText}``oldText` 必填,`newText` 省略视为删除 |
四条铁律:
1. `oldText` 必须与文件原文**逐字符完全一致**(含空格、缩进、换行),且在**原文件**中**唯一**。
2. 每条 edit 都对照**原始文件**定位,**不是**「上一条改完之后」的结果 → **不要**写重叠或嵌套的
edit;同一处或相邻的多处改动请**合并进同一条** edit。
3. `oldText` 尽量**短但保证唯一**;**不要**用一大段未改动的内容去「连接」两处相距很远的改动。
4. 一次调用传入全部 edit`edits[]`),**不要**为同一个文件连续发多次 `edit`
- 整批是**全有或全无**:任何一条未命中、不唯一或区间重叠 → 文件一个字节都不会被修改。
- 删除文本就把 `newText` 写成 `""`;要改多个文件就分多次调用(不同 `path`)。
### 3.5 执行顺序与并发
- 同一批里只要有一个 `bash`,**整批串行**执行(bash 是 sequential 工具)。
- `read` / `write` / `edit` 会**并发**执行 → **同一批次内不要对同一个文件发多个 `edit`/`write`**
(先后顺序不保证)。
- 无依赖的多个动作尽量放在同一批连续调用(减少往返);有依赖关系时再分轮次。
## 4. 工具调用方式
- 通过 **function calling**(请求里的 `tools` 参数)调用工具。**只有 function call 会被执行。**
- **严禁**在正文里写 `<bash>...</bash>``<read>...</read>` 之类的伪工具调用——
那些只是文本,`write`/`edit` 绝不会被执行。
- 参数必须是**正确的 JSON 类型**:`offset`/`limit`/`timeout` 传数字(不要传 `"10"`);
`edits` 传数组(不要传 JSON 字符串);不要给用不到的参数传 `null`
- 一次需要多个动作就连续发多次 function call,**全部完成后再**统一总结。
## 5. 工具失败与异常处理
| 工具返回 | 含义与你的动作 |
|---|---|
| `文件不存在: <绝对路径>` | 路径写错了。用 `bash``dir` / `ls` 确认真实路径,**不要猜** |
| `path 不能为空` / `command 不能为空` | 参数缺失,补齐后重试 |
| `[起始行 offset=N 超出文件范围,该文件共 M 行]` | 用 M 以内的 offset 重读 |
| `[文件为空(0 行)]` | 文件确实为空 → 用 `write` |
| `命令超时(>Ns)已终止` | 收窄命令范围,或显式传更大的 `timeout`(≤600)后重试 |
| `参数校验失败: …;收到的参数: …` | 按提示修正类型/必填项(注意别传数字字符串) |
| `第 i 条 edit 匹配到 N 处(要求唯一)` | 加长 `oldText` 上下文使其唯一 |
| `第 i 条 edit 未找到匹配文本` | 先 `read` 确认原文;检查是否把行号+TAB 前缀带进了 `oldText` |
| `edits[i] 与 edits[j] 区域重叠` | 合并成一条 edit,或改成互不相交的改动 |
| `工具调用 X 未执行(length):…` | 上一轮回复被输出长度截断 → 缩短回答或拆分步骤重试 |
| `操作已中止 (Operation aborted)` | 用户主动中止 → 停止动作,简要说明当前进度 |
连续两次同样的失败就**停下来向用户说明卡点**,不要用相似命令反复硬试。
## 6. 工作方式
1. **先看清再动手**:改代码前先 `read` / `bash` 确认现状;不凭空猜路径、函数名、行号。
2. **小步快跑**:一次做一个明确改动;改完立刻用 `bash` 验证(编译、测试、脚本)。
3. **验证要真实**:说「已完成」之前必须有工具输出作证据(命令结果 / 测试结果)。
4. **推荐流程**:定位(`grep` / `dir`)→ 精读(`read`)→ 改动(`edit` / `write`)→ 验证(`bash`)。
5. **范围克制**:只做用户要求的事;不顺手重构、不批量格式化、不改无关文件。
6. **不谎报**:没跑过的命令不说「已运行」;没读到的内容不说「文件里是…」;失败就照实说失败。
7. **输出克制**:结论先行、简洁;长内容用列表/表格;不复述用户原话;涉及文件时写清路径。
8. **连续行动**:一个任务需要多步就直接连续调用工具,中间不必停下来征求同意。
9. **该停就停**:遇到破坏性操作、路径歧义、多种方案取舍等需要用户决策的情况,先问再做。
## 7. 安全红线(必须遵守)
- **禁止**删除或覆盖项目外的文件;**禁止** `rm -rf``del /s`、格式化等破坏性命令
(除非用户在本轮明确要求并给出路径)。
- **禁止**读取或输出 `data/config.json` 中的 API 密钥等敏感内容
(可以确认文件存在,但不要展示内容)。
- **禁止**向 conda 环境 `haocode` 安装或卸载包;**禁止**修改系统目录、注册表、环境变量。
- 网络请求只允许用户已配置的 API 端点;不要主动上传数据或抓取外部内容。
- 涉及用户数据(`data/*.db`)默认只读;除用户明确要求,不要写入或迁移数据。
## 8. 会话与上下文
- **上下文可能被自动压缩**:压缩后你看到的是摘要加最近若干轮。以摘要为准继续,
不要假装记得已被压缩掉的细节;需要时重新用工具查证。
- **输出可能被截断**:若收到「未执行(length)」类提示,说明上一轮输出触达长度上限,
请缩短回答或把任务拆成更小的步骤。
- **工具调用与结果会留在历史里**:不要重复已经做过的探查,也不要凭「上一轮我大概做过什么」
下结论而不看返回结果。
- 用户可能随时中止(`操作已中止`):如实说明当前进度,不要继续动作。
Binary file not shown.
View File
+58
View File
@@ -0,0 +1,58 @@
"""
core/agent —— pi agent 核心框架的 Python 1:1 重构
==================================================
模块对照(pi-main → 本包):
packages/agent/src/types.ts → types.py 数据模型(消息/事件/工具/配置)
packages/agent/src/agent-loop.ts → loop.py 核心循环(runLoop 1:1
packages/agent/src/agent.ts → agent.py Agent 状态机(prompt/steer/followUp/abort
packages/ai/src/api/*.ts → stream_fn.py OpenAI 兼容流式(vLLM+ 输出预算钳制
packages/ai/src/api/simple-options→ context.py token 估算 + clampMaxTokensToContext
packages/agent/src/compaction.ts → compaction.py 上下文压缩(切分/摘要/替换)
agent-session.ts 后置恢复逻辑 → recovery.py 重试退避 + 溢出/截断压缩恢复
packages/agent/src/tools/*.ts → tools.py 工具管线 + 内置 read/bash/write/edit
使用示例(最小闭环):
from core.agent import Agent, AgentConfig, ModelConfig
from core.agent.stream_fn import openai_stream
from core.agent.recovery import AgentRunner
from core.agent.tools import default_tools
cfg = AgentConfig(model=ModelConfig(...), tools=default_tools())
agent = Agent(cfg)
agent.set_stream_fn(openai_stream)
runner = AgentRunner(agent)
result = runner.run("你好")
"""
from .types import (AgentConfig, AgentError, AgentEvent, AgentMessage,
AgentState, AgentTool, AgentToolResult, AbortSignal,
AssistantMessageEvent, ModelConfig, RetryConfig, RunResult,
ToolCall, new_id)
from .agent import Agent
from .context import (CONTEXT_SAFETY_TOKENS, calculate_context_tokens,
clamp_max_tokens_to_context,
estimate_context_tokens, estimate_message_tokens,
should_compact)
from .compaction import (CompactionSettings,
DEFAULT_COMPACTION_SETTINGS, compact_context,
find_cut_point, prepare_compaction)
from .recovery import (AgentRunner, compute_retry_delay_ms, find_last_assistant,
is_context_overflow, is_recoverable_length,
is_retryable_assistant_error)
from .stream_fn import (classify_error, from_openai_messages, openai_stream,
to_openai_messages)
from .tools import default_tools
# 🆕 版本记录:核心框架为 pi (badlogic/pi-mono) 0.81.x 时代的 Python 移植,
# 2026-09 对齐 pi 0.85.1 的估算/锚定/钳制/重试语义(P0/P1/P2/M1/M2/M3)。
__version__ = "1.0.0 (pi 0.81.x port, aligned 0.85.1 semantics)"
__all__ = [
"Agent", "AgentConfig", "AgentError", "AgentEvent", "AgentMessage",
"AgentRunner", "AgentState", "AgentTool", "AgentToolResult",
"AbortSignal", "AssistantMessageEvent", "CONTEXT_SAFETY_TOKENS",
"ModelConfig", "RetryConfig", "RunResult", "ToolCall",
"clamp_max_tokens_to_context", "calculate_context_tokens", "classify_error", "compact_context",
"compute_retry_delay_ms", "default_tools", "estimate_context_tokens",
"find_last_assistant", "find_cut_point", "is_context_overflow",
"is_recoverable_length", "is_retryable_assistant_error", "new_id",
"openai_stream", "should_compact", "to_openai_messages",
]
+204
View File
@@ -0,0 +1,204 @@
"""
core/agent/agent.py
===================
🌟 pi Agent 状态机的 Python 1:1 移植
对照 pi-main 源码:packages/agent/src/agent.ts (592 行)
class Agent {
state: AgentState # messages / isStreaming / error
config: AgentConfig
private listeners: Set<cb>
subscribe(cb) => unsubscribe # 事件订阅(TUI/Qt 都靠它)
prompt(message | text) => Promise # 追加 user 消息并跑一轮 run-to-settle
continue() => Promise # 从当前上下文继续(不能以 assistant 结尾)
steer(text) # 中途注入(turn 边界消费)
followUp(text) # 停止后注入(agent 停止时消费 → 续跑)
abort() # 中止当前 run(下一边界生效)
}
关键语义(与 pi 完全一致):
1. steering 在「循环开始前」和「每个 turn 结束后」被消费
one-at-a-time 每次取 1 条;"all" 一次取光)
2. followUp 只在「run 即将结束」时被外层循环消费 → 触发续跑
3. abort 不抛异常:信号置位 → 当前流/工具在下一检查点收尾 →
助手消息 stop_reason="aborted" → agent_end
4. 同一个 Agent 实例可反复 promptstate.messages 持续累积 = 会话记忆)
"""
from __future__ import annotations
import threading
from typing import Any, Callable, List, Optional, Union
from .loop import run_loop
from .types import (AgentConfig, AgentError, AgentEvent, AgentMessage,
AgentState, AbortSignal, RunResult, new_id)
class Agent:
def __init__(self, config: AgentConfig):
self.config = config
self.state = AgentState()
self._listeners: List[Callable[[AgentEvent], None]] = []
self._steering_queue: List[AgentMessage] = []
self._follow_up_queue: List[AgentMessage] = []
self._lock = threading.Lock()
self._active_signal: Optional[AbortSignal] = None
# ------------------------------------------------------------------
# 事件订阅 —— 对照 agent.ts subscribe()
# ------------------------------------------------------------------
def subscribe(self, cb: Callable[[AgentEvent], None]) -> Callable[[], None]:
self._listeners.append(cb)
def unsubscribe():
try:
self._listeners.remove(cb)
except ValueError:
pass
return unsubscribe
def _emit(self, event: AgentEvent):
for cb in list(self._listeners):
try:
cb(event)
except Exception:
# 监听器异常不打断循环(pi 同款宽容策略)
pass
# ------------------------------------------------------------------
# 队列操作 —— 对照 agent.ts 的 getSteeringMessages / getFollowUpMessages
# ------------------------------------------------------------------
def _take_steering(self) -> List[AgentMessage]:
mode = self.config.steering_mode
with self._lock:
if not self._steering_queue:
return []
if mode == "all":
out = self._steering_queue[:]
self._steering_queue.clear()
else: # one-at-a-timepi 默认)
out = [self._steering_queue.pop(0)]
return out
def _take_follow_ups(self) -> List[AgentMessage]:
mode = self.config.follow_up_mode
with self._lock:
if not self._follow_up_queue:
return []
if mode == "all":
out = self._follow_up_queue[:]
self._follow_up_queue.clear()
else:
out = [self._follow_up_queue.pop(0)]
return out
def has_queued(self) -> bool:
with self._lock:
return bool(self._steering_queue or self._follow_up_queue)
# ------------------------------------------------------------------
# 用户入口 —— 对照 agent.ts prompt / steer / followUp / abort
# ------------------------------------------------------------------
def prompt(self, message: Union[str, AgentMessage]) -> RunResult:
"""追加一条 user 消息并运行到 settle(同步阻塞,跑在调用线程上)"""
if self.state.is_streaming:
raise AgentError(message="Agent 正在运行中,不能并发 prompt(请先 abort",
kind="unknown")
if isinstance(message, str):
message = AgentMessage(role="user", content=message)
if message.role != "user":
raise AgentError(message="prompt 只接受 user 消息", kind="unknown")
self.state.messages.append(message)
return self._run(new_message=message)
def continue_(self) -> RunResult:
"""
对照 agent.ts continue() / runAgentLoopContinue 前置检查:
- 上下文不能为空
- 不能以 assistant 消息结尾
"""
if self.state.is_streaming:
raise AgentError(message="Agent 正在运行中", kind="unknown")
if not self.state.messages:
raise AgentError(message="上下文为空,无法 continue", kind="unknown")
if self.state.messages[-1].role == "assistant":
raise AgentError(
message="不能以 assistant 消息结尾来 continue(应先注入 user/toolResult",
kind="unknown")
return self._run(new_message=None)
def steer(self, text: str):
"""中途注入:在当前 run 的 turn 边界被消费(pi steering"""
with self._lock:
self._steering_queue.append(AgentMessage(role="user", content=text))
def follow_up(self, text: str):
"""停止后注入:run 即将结束时被外层循环消费 → 自动续跑(pi followUp"""
with self._lock:
self._follow_up_queue.append(AgentMessage(role="user", content=text))
def abort(self):
"""
中止当前 run。
对照 pi: 置位 AbortSignal;当前流在 chunk 边界关闭,
工具在检查点返回 "Operation aborted",助手消息以 "aborted" 收尾。
"""
sig = self._active_signal
if sig is not None:
sig.abort("aborted")
with self._lock:
# 与 pi 一致:未消费的队列消息保留,下次 run 生效
pass
# ------------------------------------------------------------------
# 内部:一次 run(同步)
# ------------------------------------------------------------------
def _run(self, new_message: Optional[AgentMessage]) -> RunResult:
signal = AbortSignal()
self._active_signal = signal
self.state.is_streaming = True
self.state.error = None
try:
result = run_loop(self, new_message, signal, self._make_stream_fn())
except AgentError:
raise
except Exception as e:
self.state.error = AgentError(message=f"循环异常: {e}", kind="unknown")
self._emit(AgentEvent(type="agent_end", stop_reason="error",
error=self.state.error))
result = RunResult(stop_reason="error", error=self.state.error)
finally:
self.state.is_streaming = False
self.state.streaming_message = None
self.state.streaming_delta = {}
self._active_signal = None
return result
def _finish_run(self, new_messages: List[AgentMessage], stop_reason: str,
error: Optional[AgentError]):
"""循环收尾回调(loop 层调用)"""
if error is not None:
self.state.error = error
# 本轮新消息已在循环中逐条 append 进 state.messages;此处仅记录收尾
self._last_run = (new_messages, stop_reason)
# ------------------------------------------------------------------
# 流函数(由 AgentWorker 注入具体 provider 实现;默认报错)
# ------------------------------------------------------------------
def set_stream_fn(self, stream_fn: Callable):
"""
stream_fn(context, model, signal, max_tokens, tools=None) -> Iterator[(kind, payload)]
对照 pi 的 streamFn 注入点(agentLoopConfig.streamFn)。
tools 参数 = 本轮可用的 AgentTool 列表,需序列化进 provider 请求。
"""
self._stream_fn = stream_fn
def _make_stream_fn(self) -> Callable:
fn = getattr(self, "_stream_fn", None)
if fn is None:
def _missing(context, model, signal, max_tokens, tools=None):
raise AgentError(message="未配置 stream_fn", kind="unknown")
yield # pragma: no cover
return _missing
return fn
+637
View File
@@ -0,0 +1,637 @@
"""
core/agent/compaction.py
========================
🌟 pi 上下文压缩算法的 Python 1:1 移植(harness 原版算法)
对照 pi-main 源码(逐函数对应):
packages/agent/src/harness/compaction/compaction.ts
DEFAULT_COMPACTION_SETTINGS -> CompactionSettings / DEFAULT_COMPACTION_SETTINGS
shouldCompact -> 见 context.py: should_compact(同一公式)
estimateTokens / estimateContextTokens -> context.pyusage 锚定,CJK 感知见说明)
findValidCutPoints / findCutPoint -> find_valid_cut_points / find_cut_point
findTurnStartIndex -> find_turn_start
prepareCompaction -> prepare_compaction
SUMMARIZATION_SYSTEM_PROMPT -> 同名(逐字移植)
SUMMARIZATION_PROMPT -> 同名(逐字移植)
UPDATE_SUMMARIZATION_PROMPT -> 同名(逐字移植,迭代更新用)
TURN_PREFIX_SUMMARIZATION_PROMPT -> 同名(逐字移植,断轮前缀用)
generateSummaryWithUsage -> generate_summarymaxTokens = 0.8×reserve
generateTurnPrefixSummary -> generate_turn_prefix_summary0.5×reserve
compact -> compact_context(断轮双摘要 + 拼接格式 1:1)
packages/agent/src/harness/compaction/utils.ts
serializeConversation -> serialize_conversation1:1,含 2000 字符截断)
extractFileOpsFromMessage -> extract_file_ops_from_message
computeFileLists -> compute_file_lists
formatFileOperations -> format_file_operations
TOOL_RESULT_MAX_CHARS = 2000 -> 同名常量
摘要 LLM 调用由上层注入:
summarize_fn(prompt_text: str, system_prompt: str, max_tokens: int) -> str
pi 里是 models.completeSimple + retryhaocode 用 OpenAI 客户端非流式调用,
由 llm_engine.AgentWorker 实现并注入。)
已声明的偏差(仅 2 处,见 context.py 头注):
1. 单条消息 token 估算用 CJK 感知启发式(pi 是 chars/4)——对中文会话更安全
2. 摘要 LLM 调用失败时降级为机械摘录(pi 返回 CompactionError)——桌面应用优先不丢上下文
其余全部 1:1:触发公式、usage 锚定、token 预算切点、有效切点规则、断轮双摘要、
迭代式 previousSummary 更新、摘要提示词逐字、文件操作附录、拼接格式。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Callable, List, Optional, Set, Tuple
from .types import AgentMessage, ModelConfig
# ======================================================================
# 压缩设置 —— 1:1 对照 DEFAULT_COMPACTION_SETTINGS
# ======================================================================
@dataclass
class CompactionSettings:
"""pi: interface CompactionSettings { enabled; reserveTokens; keepRecentTokens }"""
enabled: bool = True
reserve_tokens: int = 16384 # pi: 16384(摘要提示词与输出预留)
keep_recent_tokens: int = 20000 # pi: 20000(压缩后保留的近期上下文预算)
DEFAULT_COMPACTION_SETTINGS = CompactionSettings()
# pi utils.ts: const TOOL_RESULT_MAX_CHARS = 2000
TOOL_RESULT_MAX_CHARS = 2000
# ======================================================================
# 摘要提示词 —— 从 pi compaction.ts 逐字移植(不得改写,摘要质量依赖它)
# ======================================================================
SUMMARIZATION_SYSTEM_PROMPT = \
"You are a context summarization assistant. Your task is to read a conversation " \
"between a user and an AI assistant, then produce a structured summary following " \
"the exact format specified.\n\n" \
"Do NOT continue the conversation. Do NOT respond to any questions in the " \
"conversation. ONLY output the structured summary."
SUMMARIZATION_PROMPT = """The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]
Keep each section concise. Preserve exact file paths, function names, and error messages."""
UPDATE_SUMMARIZATION_PROMPT = """The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
Update the existing structured summary with new information. RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it
Use this EXACT format:
## Goal
[Preserve existing goals, add new ones if the task expanded]
## Constraints & Preferences
- [Preserve existing, add new ones discovered]
## Progress
### Done
- [x] [Include previously done items AND newly completed items]
### In Progress
- [ ] [Current work - update based on progress]
### Blocked
- [Current blockers - remove if resolved]
## Key Decisions
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
## Next Steps
1. [Update based on current state]
## Critical Context
- [Preserve important context, add new if needed]
Keep each section concise. Preserve exact file paths, function names, and error messages."""
TURN_PREFIX_SUMMARIZATION_PROMPT = """This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
Summarize the prefix to provide context for the retained suffix:
## Original Request
[What did the user ask for in this turn?]
## Early Progress
- [Key decisions and work done in the prefix]
## Context for Suffix
- [Information needed to understand the retained recent work]
Be concise. Focus on what's needed to understand the kept suffix."""
# ======================================================================
# 对话序列化 —— 1:1 对照 utils.ts serializeConversation
# ======================================================================
def _safe_json(value) -> str:
try:
s = json.dumps(value, ensure_ascii=False)
return s if s is not None else "undefined"
except Exception:
return "[unserializable]"
def _content_text(content) -> str:
"""pi contentText: str 或 [{type:"text",text}] 列表取文本拼接"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict) and b.get("type") == "text":
parts.append(str(b.get("text", "")))
return "\n".join(p for p in parts if p)
return str(content)
def _truncate_for_summary(text: str, max_chars: int) -> str:
"""pi utils.ts truncateForSummary(逐字逻辑)"""
if len(text) <= max_chars:
return text
truncated = len(text) - max_chars
return f"{text[:max_chars]}\n\n[... {truncated} more characters truncated]"
def serialize_conversation(messages: List[AgentMessage]) -> str:
"""
1:1 对照 utils.ts serializeConversation(输出格式逐字一致):
[User]: ...
[Assistant thinking]: ...
[Assistant]: ...
[Assistant tool calls]: name(k=v, k2=v2); name2(...)
[Tool result]: ...(超 2000 字符截断)
"""
parts: List[str] = []
for msg in messages:
if msg.role == "user":
content = _content_text(msg.content)
if content:
parts.append(f"[User]: {content}")
elif msg.role == "assistant":
thinking_parts = []
tool_calls = []
if msg.reasoning:
thinking_parts.append(msg.reasoning)
text = _content_text(msg.content)
for tc in (msg.tool_calls or []):
args_str = ", ".join(f"{k}={_safe_json(v)}"
for k, v in (tc.arguments or {}).items())
tool_calls.append(f"{tc.name}({args_str})")
if thinking_parts:
parts.append(f"[Assistant thinking]: {chr(10).join(thinking_parts)}")
if text:
parts.append(f"[Assistant]: {text}")
if tool_calls:
parts.append(f"[Assistant tool calls]: {'; '.join(tool_calls)}")
elif msg.role == "toolResult":
content = _content_text(msg.content)
if content:
parts.append(f"[Tool result]: "
f"{_truncate_for_summary(content, TOOL_RESULT_MAX_CHARS)}")
return "\n\n".join(parts)
# ======================================================================
# 文件操作提取 —— 1:1 对照 utils.ts extractFileOps*/computeFileLists/formatFileOperations
# ======================================================================
class FileOperations:
def __init__(self):
self.read: Set[str] = set()
self.written: Set[str] = set()
self.edited: Set[str] = set()
def extract_file_ops_from_message(message: AgentMessage, file_ops: FileOperations):
"""pi: assistant 的 toolCall 参数里 path 字段 → read/write/edit 归类"""
if message.role != "assistant":
return
for tc in (message.tool_calls or []):
args = tc.arguments or {}
path = args.get("path")
if not isinstance(path, str) or not path:
continue
if tc.name == "read":
file_ops.read.add(path)
elif tc.name == "write":
file_ops.written.add(path)
elif tc.name == "edit":
file_ops.edited.add(path)
def compute_file_lists(file_ops: FileOperations) -> Tuple[List[str], List[str]]:
"""pi computeFileLists: modified = edited|writtenreadOnly = read-modified;均排序"""
modified = file_ops.edited | file_ops.written
read_only = sorted(f for f in file_ops.read if f not in modified)
return read_only, sorted(modified)
def format_file_operations(read_files: List[str], modified_files: List[str]) -> str:
"""pi formatFileOperations: <read-files>/<modified-files> 标签拼接"""
sections = []
if read_files:
sections.append("<read-files>\n" + "\n".join(read_files) + "\n</read-files>")
if modified_files:
sections.append("<modified-files>\n" + "\n".join(modified_files) + "\n</modified-files>")
if not sections:
return ""
return "\n\n" + "\n\n".join(sections)
# ======================================================================
# 切分点 —— 1:1 对照 findValidCutPoints / findTurnStartIndex / findCutPoint
# ======================================================================
@dataclass
class CutPointResult:
"""pi: interface CutPointResult"""
first_kept_index: int # 保留段首条在 compactable 列表里的下标
turn_start_index: int = -1 # 断轮时:该轮起点(user 消息)下标;否则 -1
is_split_turn: bool = False
def find_valid_cut_points(messages: List[AgentMessage],
start_index: int, end_index: int) -> List[int]:
"""
pi findValidCutPoints:消息角色为 user/assistant 的位置是有效切点
toolResult 不能做切点——它会与前面的 toolCall 分离)。
pi 里的 bashExecution/branchSummary/compactionSummary 等角色
在 haocode 消息模型中不存在,等价规则即 role in (user, assistant)。
"""
cut_points = []
for i in range(start_index, end_index):
if messages[i].role in ("user", "assistant"):
cut_points.append(i)
return cut_points
def find_turn_start(messages: List[AgentMessage], entry_index: int,
start_index: int) -> int:
"""pi findTurnStartIndex:向前找本轮起点(user 消息 / branch_summary"""
for i in range(entry_index, start_index - 1, -1):
if messages[i].role == "user":
return i
return -1
def _estimate(msg: AgentMessage) -> int:
from .context import estimate_message_tokens
return estimate_message_tokens(msg)
def find_cut_point(messages: List[AgentMessage], start_index: int,
end_index: int, keep_recent_tokens: int) -> CutPointResult:
"""
1:1 对照 pi findCutPoint
1. 从尾部向前累计 token,直到累计 >= keep_recent_tokens
2. 取该位置(含)之后的第一个有效切点
3. 切点不是 user 消息 → 断轮:找本轮起点,前缀单独摘要
(pi 的「回退跳过状态条目」循环针对 session 状态条目;
haocode 消息列表没有状态条目,等价省略。)
"""
cut_points = find_valid_cut_points(messages, start_index, end_index)
if not cut_points:
return CutPointResult(first_kept_index=start_index)
accumulated = 0
cut_index = cut_points[0]
for i in range(end_index - 1, start_index - 1, -1):
accumulated += _estimate(messages[i])
if accumulated >= keep_recent_tokens:
for c in cut_points:
if c >= i:
cut_index = c
break
break
is_user = messages[cut_index].role == "user"
turn_start = -1 if is_user else find_turn_start(messages, cut_index, start_index)
is_split = (not is_user) and turn_start != -1
return CutPointResult(first_kept_index=cut_index,
turn_start_index=turn_start,
is_split_turn=is_split)
# ======================================================================
# 压缩准备 —— 1:1 对照 prepareCompaction
# ======================================================================
@dataclass
class CompactionPreparation:
"""pi: interface CompactionPreparation"""
messages_to_summarize: List[AgentMessage] = field(default_factory=list)
turn_prefix_messages: List[AgentMessage] = field(default_factory=list)
retained_tail: List[AgentMessage] = field(default_factory=list)
is_split_turn: bool = False
tokens_before: int = 0
previous_summary: Optional[str] = None
file_ops: FileOperations = field(default_factory=FileOperations)
settings: CompactionSettings = DEFAULT_COMPACTION_SETTINGS
def _compaction_diag(line: str) -> None:
"""压缩自诊断日志(与 recovery.compact_diag_log 同一文件/规范:
print + 追加 compaction_diag.log + 失败静默)。"""
import os as _os
import time as _time
t = _time.time()
stamp = (f"[{_time.strftime('%H:%M:%S', _time.localtime(t))}"
f".{int(t * 1000) % 1000:03d}]")
print(f"{stamp} {line}", flush=True)
try:
path = _os.path.join(
_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))),
"compaction_diag.log")
with open(path, "a", encoding="utf-8") as f:
f.write(f"{stamp} {line}\n")
except Exception:
pass
def _nothing_to_summarize_diag(messages: List[AgentMessage],
compactable: List[AgentMessage],
cut: CutPointResult,
settings: CompactionSettings) -> str:
"""prepare_compaction 返回 None(无可摘要内容)时的自诊断行:
记录分支子类型 + 关键量,下次失败可直接从日志定位原因。
子类型:
no_valid_cut_points —— 可压缩范围内没有任何 user/assistant 条目
total_below_keep_recent —— 可压缩范围总量 < keep_recent(通常意味着
上下文主体是旧摘要本身,无新内容)
cut_pinned_at_zero —— 预算从尾部累加只在 i=0 才达标,即首条
条目独占 ≥ (总量-keep_recent) 的 token
(单条超长消息/巨型工具输出主导上下文)
"""
from .context import estimate_context_tokens, estimate_message_tokens
try:
total = estimate_context_tokens(messages).tokens
n_user = sum(1 for m in compactable if m.role == "user")
n_asst = sum(1 for m in compactable if m.role == "assistant")
ests = [(estimate_message_tokens(m), i) for i, m in enumerate(compactable)]
e0 = ests[0][0] if ests else 0
rest = sum(e for e, _ in ests[1:])
top3 = sorted(ests, reverse=True)[:3]
roles = " ".join(("T" if m.role == "toolResult" else m.role[0].upper())
for m in compactable[:10])
if not find_valid_cut_points(compactable, 0, len(compactable)):
sub = "no_valid_cut_points"
elif sum(e for e, _ in ests) < settings.keep_recent_tokens:
sub = "total_below_keep_recent"
else:
sub = "cut_pinned_at_zero"
big = "; ".join(f"idx{i}={e}" for e, i in top3)
return (f"[COMPACT_NONE] branch=nothing_to_summarize sub={sub} "
f"msgs={len(messages)} est_total={total} compactable={len(compactable)} "
f"keep_recent={settings.keep_recent_tokens} "
f"first_kept={cut.first_kept_index} turn_start={cut.turn_start_index} "
f"split={cut.is_split_turn} user={n_user} asst={n_asst} "
f"est_first={e0} est_rest={rest} top3=[{big}] head_roles=[{roles}]")
except Exception as ex: # 诊断本身失败不能影响主流程
return f"[COMPACT_NONE] branch=nothing_to_summarize (diag failed: {ex})"
def prepare_compaction(messages: List[AgentMessage],
settings: Optional[CompactionSettings] = None
) -> Optional[CompactionPreparation]:
"""
1:1 对照 pi prepareCompaction
- 上一条压缩摘要(messages[0].kind == "compaction_summary")不重复摘要,
其内容作为 previousSummary 走迭代更新提示词
- 可压缩范围 = 摘要之后的全部消息(pi 里等价于「上次保留尾 + 新消息」)
- 切点在可压缩范围内选;tokens_before 按完整上下文(含摘要消息)估算
不可压缩(空/无摘要对象)时返回 None(对照 pi 返回 ok(undefined))。
"""
from .context import estimate_context_tokens
settings = settings or DEFAULT_COMPACTION_SETTINGS
if not messages:
_compaction_diag("[COMPACT_NONE] branch=empty_messages")
return None
if messages[-1].kind == "compaction_summary":
_compaction_diag(
f"[COMPACT_NONE] branch=tail_is_summary msgs={len(messages)} "
f"tail_head={_content_text(messages[-1].content)[:60]!r}")
return None
previous_summary = None
if messages[0].kind == "compaction_summary":
previous_summary = _content_text(messages[0].content)
compactable = messages[1:]
else:
compactable = messages
if not compactable:
_compaction_diag("[COMPACT_NONE] branch=compactable_empty (上下文只剩旧摘要)")
return None
tokens_before = estimate_context_tokens(messages).tokens
cut = find_cut_point(compactable, 0, len(compactable),
settings.keep_recent_tokens)
history_end = cut.turn_start_index if cut.is_split_turn else cut.first_kept_index
messages_to_summarize = compactable[:history_end]
turn_prefix_messages = []
if cut.is_split_turn:
turn_prefix_messages = compactable[cut.turn_start_index:cut.first_kept_index]
retained_tail = compactable[cut.first_kept_index:]
if not messages_to_summarize and not turn_prefix_messages:
# 🆕 自诊断:记录是哪种子条件导致无东西可摘要(见 _nothing_to_summarize_diag
_compaction_diag(_nothing_to_summarize_diag(messages, compactable, cut,
settings))
return None # 没有可摘要内容
file_ops = FileOperations()
for m in messages_to_summarize:
extract_file_ops_from_message(m, file_ops)
if cut.is_split_turn:
for m in turn_prefix_messages:
extract_file_ops_from_message(m, file_ops)
return CompactionPreparation(
messages_to_summarize=messages_to_summarize,
turn_prefix_messages=turn_prefix_messages,
retained_tail=retained_tail,
is_split_turn=cut.is_split_turn,
tokens_before=tokens_before,
previous_summary=previous_summary,
file_ops=file_ops,
settings=settings,
)
# ======================================================================
# 摘要生成 —— 1:1 对照 generateSummaryWithUsage / generateTurnPrefixSummary
# ======================================================================
def _build_summary_prompt(conversation_text: str, previous_summary: Optional[str]) -> str:
"""pi generateSummaryWithUsage 的 prompt 组装(逐字结构)"""
base = UPDATE_SUMMARIZATION_PROMPT if previous_summary else SUMMARIZATION_PROMPT
prompt = f"<conversation>\n{conversation_text}\n</conversation>\n\n"
if previous_summary:
prompt += f"<previous-summary>\n{previous_summary}\n</previous-summary>\n\n"
prompt += base
return prompt
def generate_summary(messages: List[AgentMessage],
summarize_fn: Callable[[str, str, int], str],
reserve_tokens: int,
model_max_tokens: int,
previous_summary: Optional[str] = None
) -> Tuple[Optional[str], Optional[str]]:
"""
返回 (summary_text, error)。
maxTokens = min(0.8 × reserveTokens, model.maxTokens) —— 1:1 对照。
"""
max_tokens = min(
int(0.8 * reserve_tokens),
model_max_tokens if model_max_tokens > 0 else (1 << 30),
)
conversation = serialize_conversation(messages)
prompt = _build_summary_prompt(conversation, previous_summary)
try:
text = summarize_fn(prompt, SUMMARIZATION_SYSTEM_PROMPT, max_tokens)
except Exception as e:
return None, f"Summarization failed: {e}"
if not text or not text.strip():
return None, "Summarization failed: empty response"
return text.strip(), None
def generate_turn_prefix_summary(messages: List[AgentMessage],
summarize_fn: Callable[[str, str, int], str],
reserve_tokens: int,
model_max_tokens: int
) -> Tuple[Optional[str], Optional[str]]:
"""maxTokens = min(0.5 × reserveTokens, model.maxTokens) —— 1:1 对照"""
max_tokens = min(
int(0.5 * reserve_tokens),
model_max_tokens if model_max_tokens > 0 else (1 << 30),
)
conversation = serialize_conversation(messages)
prompt = f"<conversation>\n{conversation}\n</conversation>\n\n{TURN_PREFIX_SUMMARIZATION_PROMPT}"
try:
text = summarize_fn(prompt, SUMMARIZATION_SYSTEM_PROMPT, max_tokens)
except Exception as e:
return None, f"Turn prefix summarization failed: {e}"
if not text or not text.strip():
return None, "Turn prefix summarization failed: empty response"
return text.strip(), None
# ======================================================================
# 主入口 —— 1:1 对照 compact()
# ======================================================================
def compact_context(messages: List[AgentMessage],
model: ModelConfig,
summarize_fn: Callable[[str, str, int], str],
settings: Optional[CompactionSettings] = None
) -> Optional[List[AgentMessage]]:
"""
执行压缩。返回 [压缩摘要消息] + 保留尾巴;不可压缩时返回 None。
摘要消息: role="user", kind="compaction_summary"content 为纯摘要文本
pi 的 compaction 条目;下次压缩时自动走迭代更新提示词)。
断轮(切点落在某轮中间)时 1:1 对照 pi compact()
历史摘要 与 轮前缀摘要 分两次 LLM 调用,拼接为
{history}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix}
最后追加文件操作附录(<read-files>/<modified-files>)。
"""
settings = settings or DEFAULT_COMPACTION_SETTINGS
prep = prepare_compaction(messages, settings)
if prep is None:
return None
history_text: Optional[str] = None
prefix_error: Optional[str] = None
history_error: Optional[str] = None
if prep.is_split_turn and prep.turn_prefix_messages:
if prep.messages_to_summarize:
history_text, history_error = generate_summary(
prep.messages_to_summarize, summarize_fn,
prep.settings.reserve_tokens, model.max_tokens,
prep.previous_summary)
if history_error:
return _degraded_compact(prep, history_error)
else:
history_text = "No prior history."
prefix_text, prefix_error = generate_turn_prefix_summary(
prep.turn_prefix_messages, summarize_fn,
prep.settings.reserve_tokens, model.max_tokens)
if prefix_error:
return _degraded_compact(prep, prefix_error)
summary = (f"{history_text}\n\n---\n\n"
f"**Turn Context (split turn):**\n\n{prefix_text}")
else:
if not prep.messages_to_summarize:
return None
summary, history_error = generate_summary(
prep.messages_to_summarize, summarize_fn,
prep.settings.reserve_tokens, model.max_tokens,
prep.previous_summary)
if history_error:
return _degraded_compact(prep, history_error)
read_files, modified_files = compute_file_lists(prep.file_ops)
summary += format_file_operations(read_files, modified_files)
summary_msg = AgentMessage(role="user", content=summary,
kind="compaction_summary")
return [summary_msg] + prep.retained_tail
def _degraded_compact(prep: CompactionPreparation,
error: str) -> Optional[List[AgentMessage]]:
"""
已声明偏差(对照 pi: 直接返回 CompactionError):
桌面应用优先「不丢上下文」——摘要失败时降级为机械摘录,
保留尾巴原样不动。
"""
old = prep.messages_to_summarize + prep.turn_prefix_messages
if not old:
return None
excerpt = serialize_conversation(old)[-500:]
summary = (f"(自动压缩:摘要生成失败 [{error}],以下为旧对话尾部摘录)\n\n{excerpt}")
summary_msg = AgentMessage(role="user", content=summary,
kind="compaction_summary")
return [summary_msg] + prep.retained_tail
+341
View File
@@ -0,0 +1,341 @@
"""
core/agent/context.py
=====================
🌟 pi 上下文管理的 Python 1:1 移植 —— 令牌估算 + 输出预算钳制
对照 pi-main 源码:
packages/ai/src/api/simple-options.ts
- CONTEXT_SAFETY_TOKENS = 4096 (行 12)
- clampMaxTokensToContext() (行 12-34)
maxOutput = contextWindow - inputTokens - 4096
return min(model.maxTokens, maxOutput) (下限 1)
- clampOutputsToContext() (行 36-60)
多模型共享窗口时按比例分配剩余输出预算
packages/ai/src/api/token-utils.ts
- 每条消息估算: ceil(chars/4) + 4
🌟 已声明偏差(仅 1 处,其余全部 1:1):
pi 按 chars/4 估算(针对英文)。中文 1 字 ≈ 1 token,chars/4 会严重低估。
这里采用 CJK 感知估算:CJK 字符按 1 token/字,其余按 4 字符/token。
这直接影响「压缩触发时机」和「输出预算钳制」,必须更准才不会撞 vLLM 上限。
pi 的 usage 锚定机制 1:1 保留:若历史里存在有效 assistant usage
总估算 = 该 usage 的 totalTokens(服务商精确值)+ 其后消息的逐条估算。
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from .types import AgentMessage, ModelConfig
# ======================================================================
# 对照 simple-options.ts: export const CONTEXT_SAFETY_TOKENS = 4096
# 用途:上下文钳制时预留的安全余量(tokenizer 估算误差 + vLLM 精确计数的偏差)
# ======================================================================
CONTEXT_SAFETY_TOKENS = 4096
# 图片 token 估算(对照 pi 对 image part 的固定估算)
IMAGE_TOKENS = 1600
_CJK_RE = re.compile(
r"[\u2e80-\u2eff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff"
r"\uf900-\ufaff\uff00-\uffef]"
)
def _count_text_chars(value: Any) -> int:
"""统计内容里的字符数(对照 pi token-utils 的字符统计口径)"""
if isinstance(value, str):
return len(value)
if isinstance(value, list): # OpenAI 多模态 content 数组
n = 0
for part in value:
if isinstance(part, dict):
if part.get("type") == "text":
n += len(part.get("text", "") or "")
elif part.get("type") == "image_url":
n += 0 # 图片单独按 IMAGE_TOKENS 计
else:
n += len(str(part))
return n
return len(str(value or ""))
def _count_image_parts(content: Any) -> int:
if isinstance(content, list):
return sum(1 for p in content
if isinstance(p, dict) and p.get("type") == "image_url")
return 0
def estimate_message_tokens(message: AgentMessage) -> int:
"""
单条消息 token 估算(CJK 感知版)。
pi 口径: ceil(chars/4) + 4。本项目: CJK 字 ×1 + 其余 /4 + 4 + 图片×1600。
"""
raw = message.content
if isinstance(raw, str):
chars = len(raw)
cjk = len(_CJK_RE.findall(raw))
else:
# 数组 content:拼接所有文本部分
joined = []
for part in raw if isinstance(raw, list) else []:
if isinstance(part, dict) and part.get("type") == "text":
joined.append(part.get("text", "") or "")
text = "".join(joined)
chars = len(text)
cjk = len(_CJK_RE.findall(text))
tokens = cjk + math.ceil((chars - cjk) / 4) + 4
tokens += _count_image_parts(raw) * IMAGE_TOKENS
# 思考内容与工具参数也占上下文(assistant 的 reasoning / tool_calls
if message.reasoning:
rcjk = len(_CJK_RE.findall(message.reasoning))
tokens += rcjk + math.ceil((len(message.reasoning) - rcjk) / 4)
for tc in message.tool_calls:
arg_text = _json_str(tc.arguments)
tokens += math.ceil(len(arg_text) / 4)
# toolResult 的 content 走 content 字段(str 或 list
return tokens
def _json_str(obj: Any) -> str:
import json
try:
return json.dumps(obj, ensure_ascii=False)
except Exception:
return str(obj)
def _estimate_text_tokens(text: str) -> int:
"""CJK 感知文本 token 估算(与 estimate_message_tokens 同公式,不含每条 +4 开销)"""
if not text:
return 0
cjk = len(_CJK_RE.findall(text))
return cjk + math.ceil((len(text) - cjk) / 4)
def _estimate_tools_tokens(tools) -> int:
"""
🆕 P2: 工具 schema token 估算 —— name/description/parameters 的 JSON 字符数(CJK 感知)。
对照 pi Context 分支: all tools = Σ estimate_text(json(tool))。
"""
total = 0
for t in tools or []:
schema = {
"name": getattr(t, "name", "") or "",
"description": getattr(t, "description", "") or "",
"parameters": getattr(t, "parameters", None),
}
total += _estimate_text_tokens(_json_str(schema))
return total
def estimate_context_tokens(messages: List[AgentMessage],
system_prompt: str = "",
tools: Optional[List[Any]] = None
) -> "ContextUsageEstimate":
"""
🌟 1:1 对照 pi estimateContextTokensusage 锚定):
- 找到最后一条带有效 usage 的 assistant 消息(🆕 P0: 含 timestamp 时效校验)
- 总 tokens = 该 usage 的精确值 + 其后消息的逐条估算
- 无 usage 时 = 全部消息逐条估算
🆕 P2: 可选 system_prompt / tools 参数(对照 pi Context 分支):
- 仅在无锚点分支计入 system 提示词 + 工具 schema 的估算
(有锚点时 usage 已是含 system+tools 的精确总值,不重复计)
逐条估算用 CJK 感知版 estimate_message_tokens(已声明偏差)。
"""
last_idx, last_usage = _find_last_usage(messages)
sys_tokens = _estimate_text_tokens(system_prompt) if system_prompt else 0
tool_tokens = _estimate_tools_tokens(tools) if tools else 0
if last_idx is None:
estimated = (sum(estimate_message_tokens(m) for m in messages)
+ sys_tokens + tool_tokens)
return ContextUsageEstimate(tokens=estimated, usage_tokens=0,
trailing_tokens=estimated, last_usage_index=None)
usage_tokens = calculate_context_tokens(last_usage)
trailing = sum(estimate_message_tokens(m) for m in messages[last_idx + 1:])
return ContextUsageEstimate(tokens=usage_tokens + trailing,
usage_tokens=usage_tokens,
trailing_tokens=trailing,
last_usage_index=last_idx)
@dataclass
class ContextUsageEstimate:
"""
🌟 1:1 对照 pi estimateContextTokens 的返回值
packages/agent/src/harness/compaction/compaction.ts ContextUsageEstimate:
tokens 总估算
usageTokens 最后一条有效 assistant usage 报告的精确 token 数
trailingTokens 该 usage 之后消息的估算值
lastUsageIndex 提供 usage 的消息下标(无则 None)
"""
tokens: int = 0
usage_tokens: int = 0
trailing_tokens: int = 0
last_usage_index: Optional[int] = None
def calculate_context_tokens(usage: Dict[str, Any]) -> int:
"""pi calculateContextTokens: totalTokens || input+output+cacheRead+cacheWrite"""
if not usage:
return 0
total = int(usage.get("totalTokens", 0) or 0)
if total:
return total
return (int(usage.get("input", 0) or 0) + int(usage.get("output", 0) or 0)
+ int(usage.get("cacheRead", 0) or 0)
+ int(usage.get("cacheWrite", 0) or 0))
def _get_assistant_usage(msg: AgentMessage) -> Dict[str, Any]:
"""
pi getAssistantUsage:只认「有效」的 assistant usage——
stopReason 不是 aborted/error,且 calculateContextTokens > 0。
(🆕 P0: 锚点扫描还需通过 timestamp 时效校验,见 _find_last_usage
"""
if msg.role != "assistant":
return {}
if msg.stop_reason in ("aborted", "error"):
return {}
usage = msg.usage or {}
if usage and calculate_context_tokens(usage) > 0:
return usage
return {}
def _row_block_tool_flags(messages: List["AgentMessage"]) -> List[bool]:
"""
🆕 G1: 逐条标记「其 DB 行(同非空 db_msg_id 的连续块)是否含工具活动」。
含工具输出的行,其入库 usage 是「本轮最后一个子请求」的内存快照
(工具结果未受 4000 字回放截断),不等于下一请求(截断回放)→ 锚点失效。
轮中内存条目(db_msg_id="")一律 False:保持原规则
(轮中条目的内存载荷 == 下一子请求载荷,锚点有效)。
"""
n = len(messages)
flags = [False] * n
i = 0
while i < n:
rid = getattr(messages[i], "db_msg_id", "") or ""
if not rid:
i += 1
continue
j = i
has_tool = False
while j < n and (getattr(messages[j], "db_msg_id", "") or "") == rid:
mj = messages[j]
if mj.role == "toolResult" or (mj.role == "assistant" and mj.tool_calls):
has_tool = True
j += 1
for k in range(i, j):
flags[k] = has_tool
i = j
return flags
def _find_last_usage(messages: List["AgentMessage"]):
"""
🆕 P0: 1:1 对照 pi getLastAssistantUsageInfo(含锚点失效校验):
assistant 的 usage 要成为锚点,必须满足
① timestamp >= 它前面所有消息的最大 timestamp(即它不比任何前消息「更旧」)。
场景:压缩后 state = [新摘要消息(T_new)] + [保留的旧消息(T_old)]
保留窗里 assistant 的 usage 来自压缩前(如 60K 精确值)——不校验的话,
下一次 should_compact 会锚定到过期值 → 误触发二次压缩(摘要套摘要)。
摘要消息 timestamp 晚于全部保留消息 → 前缀游标抬高 → 旧 usage 自动失效;
压缩后第一个新 assistant 回复到达 → 新 usage 锚点自动恢复。
② 🆕 G1: 该 usage 所在 DB 行不含工具活动(toolResult / assistant.tool_calls)。
含工具输出的行,入库 usage = 本轮内存快照(工具结果未截断)≠ 下一请求
(4000 字截断回放)→ 系统性高估 → 误触发压缩(实测 113904 锚点 vs
真实下一请求 ~90k)。纯文本行 / 轮中内存条目的 usage 仍是精确值,保留锚定。
(消息无 timestamp 信息(全 0)时行为等同旧版「取最后一条有效」,完全向后兼容。)
"""
latest_prefix_ts = -1 # 对照 pi: Number.NEGATIVE_INFINITY
last_idx = None
last_usage: Dict[str, Any] = {}
row_tool_flags = _row_block_tool_flags(messages)
for i, msg in enumerate(messages):
if msg.role == "assistant":
applies = (msg.timestamp or 0) >= latest_prefix_ts
if (applies
and not row_tool_flags[i]
and msg.stop_reason not in ("aborted", "error")
and msg.usage
and calculate_context_tokens(msg.usage) > 0):
last_idx, last_usage = i, msg.usage
ts = msg.timestamp or 0
if ts > latest_prefix_ts:
latest_prefix_ts = ts
return last_idx, last_usage
def clamp_max_tokens_to_context(model: ModelConfig,
context: List[AgentMessage],
system_prompt: str = "",
tools: Optional[List[Any]] = None
) -> Optional[Tuple[int, int]]:
"""
🌟 对照 simple-options.ts:12-34 clampMaxTokensToContext1:1 公式):
input_estimate = estimateContextTokens(context) # 🆕 P2: 含 system+toolspi Context 实参)
if (input_estimate > model.contextWindow) return null // 上下文已溢出
maxOutput = contextWindow - inputTokens - CONTEXT_SAFETY_TOKENS
return max(1, min(model.maxTokens, maxOutput))
这就是 pi 约束公式的本体:
输入 + 输出 ≤ contextWindow 4096 ≤ vLLM max_model_len
🆕 P2: system_prompt / tools 仅在无 usage 锚点分支计入(首轮流式精确记账,
与 pi 传完整 Context 的行为一致);有锚点时 usage 已精确覆盖,不重复计。
返回 (max_tokens, input_tokens);输入已溢出窗口时返回 None(由恢复逻辑接管)。
"""
input_tokens = estimate_context_tokens(
context, system_prompt=system_prompt, tools=tools).tokens
if input_tokens > model.context_window:
return None
max_output = model.context_window - input_tokens - CONTEXT_SAFETY_TOKENS
return max(1, min(model.max_tokens, max_output)), input_tokens
def clamp_outputs_to_context(context: List[AgentMessage],
models: List[ModelConfig],
reserve_tokens: int = CONTEXT_SAFETY_TOKENS
) -> List[int]:
"""
对照 simple-options.ts:36-60 clampOutputsToContext
多个模型共享同一窗口时,把剩余输出预算按比例分配给各模型。
(haocode 单模型场景用不到,保留以求框架完整)
"""
input_tokens = estimate_context_tokens(context).tokens
available = max(0, sum(m.context_window for m in models) - input_tokens - reserve_tokens)
if not models:
return []
weights = [m.max_tokens for m in models]
total_w = sum(weights) or 1
alloc = [max(1, int(available * w / total_w)) for w in weights]
# 各自不超过自身 max_tokens
alloc = [min(a, m.max_tokens) for a, m in zip(alloc, models)]
return alloc
def should_compact(messages: List[AgentMessage], model: ModelConfig,
reserve_tokens: int = 16384,
system_prompt: str = "",
tools: Optional[List[Any]] = None) -> Tuple[bool, int]:
"""
🌟 1:1 对照 pi compaction.ts shouldCompactharness 原版公式):
contextTokens > contextWindow - reserveTokens
reserveTokens 默认 16384DEFAULT_COMPACTION_SETTINGS.reserveTokens),
即「为摘要提示词与输出预留 16K」。估算值取 usage 锚定估算。
🆕 G2: system_prompt/tools 透传 —— 无锚点分支(工具行快照锚点被 G1 失效后
更常走到)也要计入 system + 工具 schema,度量「下一请求真实载荷」,
与显示端(update_context_display)同口径。
"""
tokens = estimate_context_tokens(messages, system_prompt=system_prompt,
tools=tools).tokens
return (tokens > model.context_window - reserve_tokens), tokens
+482
View File
@@ -0,0 +1,482 @@
"""
core/agent/loop.py
==================
🌟 pi 核心循环的 Python 1:1 移植 —— 整个框架的心脏
对照 pi-main 源码(packages/agent/src/agent-loop.ts, 796 行):
runAgentLoop() → run_loop(agent, new_message, ...)
runLoop() → _run_loop() (行 163-278)
streamAssistantResponse() → _stream_turn() (行 281-380)
executeToolCalls() → execute_tool_calls() (行 413-427)
executeToolCallsParallel/Sequential → _execute_parallel/_execute_sequential
prepareToolCall() → tools.prepare_tool_calls
failToolCallsFromTruncatedMessage → tools.fail_tool_calls_from_truncated_message
shouldTerminateToolBatch → _should_terminate_batch (行 561-563)
pi runLoop 的结构(本文件逐行对应):
emit agent_start
pending = getSteeringMessages() # 循环开始前先取一次
outer: while True:
inner: while hasMoreToolCalls or pending:
turn_start(首轮不发,首轮 turn_start 在 runAgentLoop 入口发)
注入 pendingmessage_start/end → 入 context + newMessages
assistant = streamAssistantResponse(...)
if stopReason in (error, aborted): turn_end + agent_end + return
toolCalls = assistant 的 toolCall 块
if toolCalls:
length → failToolCallsFromTruncatedMessage(不执行!)
否则 → executeToolCallssequential/parallel 二选一)
hasMoreToolCalls = !batch.terminate
toolResults 入 context + newMessages
turn_end
prepareNextTurn 钩子(可换 context/model
shouldStopAfterTurn 钩子 → agent_end + return
pending = getSteeringMessages() # 每轮结束取一次
followUps = getFollowUpMessages()
if followUps: pending = followUps; continue # 外层续跑
break
agent_end
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple
from .context import clamp_max_tokens_to_context
from .tools import (PreparedToolCall, execute_tool_call, fail_tool_calls_from_truncated_message,
prepare_tool_calls)
from .types import (AgentConfig, AgentError, AgentEvent, AgentMessage,
AgentTool, AgentToolResult, AbortSignal, AssistantMessageEvent,
RunResult, ToolCall, new_id)
# ======================================================================
# 事件发射辅助
# ======================================================================
def _emit(agent, event: AgentEvent):
agent._emit(event)
def _message_events(agent, msg: AgentMessage):
_emit(agent, AgentEvent(type="message_start", message=msg))
_emit(agent, AgentEvent(type="message_end", message=msg))
def _tool_result_message(finalized: Dict[str, Any]) -> AgentMessage:
"""对照 createToolResultMessage"""
tc: ToolCall = finalized["tool_call"]
result: AgentToolResult = finalized["result"]
return AgentMessage(
role="toolResult",
tool_call_id=tc.id,
tool_name=tc.name,
content=result.content,
is_error=finalized.get("is_error", False) or result.is_error,
)
# ======================================================================
# 流式生成一轮助手消息 —— 对照 streamAssistantResponse (行 281-380)
# ======================================================================
def _stream_turn(agent, current_context: List[AgentMessage],
config: AgentConfig, signal: AbortSignal,
stream_fn: Callable) -> Tuple[AgentMessage, Optional[AgentError]]:
"""
返回 (assistant_message, error)。
error 非 None 时 message.stop_reason == "error"
"""
# 🌟 pi 同款:流开始前检查中止 → 立即产出 aborted 消息
if signal.aborted:
msg = AgentMessage(role="assistant", stop_reason="aborted")
agent.state.messages.append(msg)
_message_events(agent, msg)
return msg, None
# 🌟 系统提示词注入(对照 pi:systemPrompt 放在每次 API 请求头部,
# 不进 state.messages、不占压缩/历史)
if config.system_prompt:
api_context: List[AgentMessage] = [
AgentMessage(role="system", content=config.system_prompt)
] + current_context
else:
api_context = current_context
# 🌟 输出预算钳制(对照 simple-options clampMaxTokensToContext
# 🆕 P2: 把 system + 工具 schema 传给估算器(对照 pi 传完整 Context),
# 仅在无 usage 锚点分支生效;否则新会话首轮会多预留 ~system+tools 的预算。
clamped = clamp_max_tokens_to_context(
config.model, current_context,
system_prompt=config.system_prompt or "", tools=config.tools)
if clamped is None:
err = AgentError(
message="上下文溢出:估算输入 token 已超过模型窗口(需要压缩)",
kind="overflow", recoverable=True)
msg = AgentMessage(role="assistant", stop_reason="error",
error_message=err.message)
agent.state.messages.append(msg)
_message_events(agent, msg)
return msg, err
max_tokens, _input_tokens = clamped
msg = AgentMessage(role="assistant")
agent.state.streaming_message = msg
agent.state.streaming_delta = {}
_emit(agent, AgentEvent(type="message_start", message=msg))
raw_tc: Dict[int, Dict[str, str]] = {} # 中止时用于保留部分工具调用(仅展示)
got_final = False
error: Optional[AgentError] = None
try:
for kind, payload in stream_fn(api_context, config.model, signal,
max_tokens, config.tools):
if kind == "event":
ev = payload
if ev.type == "text_delta":
msg.content = (msg.content or "") + ev.text
agent.state.streaming_delta["text"] = ev.text
_emit(agent, AgentEvent(type="message_update", message=msg,
assistant_message_event=ev))
elif ev.type == "thinking_delta":
msg.reasoning += ev.text
agent.state.streaming_delta["thinking"] = ev.text
_emit(agent, AgentEvent(type="message_update", message=msg,
assistant_message_event=ev))
elif ev.type == "toolcall_delta":
slot = raw_tc.setdefault(ev.tool_call_index,
{"id": "", "name": "", "args": ""})
if ev.tool_call_field == "id":
slot["id"] = ev.tool_call_delta
elif ev.tool_call_field == "name":
slot["name"] += ev.tool_call_delta
else:
slot["args"] += ev.tool_call_delta
_emit(agent, AgentEvent(type="message_update", message=msg,
assistant_message_event=ev))
else: # final
final: AgentMessage = payload
got_final = True
# 采用权威 finalusage / stop_reason / 解析好的 tool_calls
msg.usage = final.usage
msg.stop_reason = final.stop_reason
msg.tool_calls = final.tool_calls
if not final.content and msg.content:
pass # 以循环侧累积为准(二者一致)
else:
msg.content = final.content if final.content else msg.content
msg.reasoning = final.reasoning or msg.reasoning
except AgentError as e:
error = e
msg.stop_reason = "error"
msg.error_message = e.message
_emit(agent, AgentEvent(type="message_update", message=msg,
assistant_message_event=
AssistantMessageEvent.error(e.message)))
except Exception as e:
from .stream_fn import classify_error
error = classify_error(e)
msg.stop_reason = "error"
msg.error_message = error.message
if not got_final and error is None:
# 生成器中途结束且无异常 = 中止(流被关闭,pi 同款语义)
msg.stop_reason = "aborted"
# 保留部分工具调用(仅用于 UI 展示;中止后不会执行,也不会进入下次上下文)
for idx in sorted(raw_tc.keys()):
slot = raw_tc[idx]
if slot.get("name"):
msg.tool_calls.append(ToolCall(id=slot.get("id") or new_id("call"),
name=slot["name"],
raw_arguments=slot.get("args", "")))
if error is None and msg.stop_reason not in ("stop", "length", "aborted"):
msg.stop_reason = "stop"
# 🌟 兜底(haocode 扩展,pi 无此层):不支持 tools API 的供应商/模型会把
# 工具调用用文字"演"出来(如 <bash>ls</bash>)。识别单参工具 bash/read
# 转成真 tool_call 继续执行;write/edit 多参歧义大不兜底。
if error is None and not msg.tool_calls and msg.content:
from .tools import parse_text_tool_calls
cleaned, txt_calls = parse_text_tool_calls(msg.content)
if txt_calls:
msg.tool_calls = txt_calls
agent.state.streaming_message = None
agent.state.streaming_delta = {}
agent.state.messages.append(msg)
_emit(agent, AgentEvent(type="message_end", message=msg))
return msg, error
# ======================================================================
# 工具批量执行 —— 对照 executeToolCalls (行 413-427)
# ======================================================================
def _should_terminate_batch(finalized: List[Dict[str, Any]]) -> bool:
"""对照 shouldTerminateToolBatch: 全部结果都请求 terminate 才终止"""
return len(finalized) > 0 and all(
f["result"].terminate for f in finalized)
def _run_prepared_in_pool(prep: PreparedToolCall, assistant: AgentMessage,
config: AgentConfig, signal: AbortSignal,
agent) -> Dict[str, Any]:
"""线程池内执行单个工具(对照 parallel 版的 async 闭包)"""
on_update = None
on_timer = None
if agent is not None:
def on_update(partial: str):
_emit(agent, AgentEvent(type="tool_execution_update",
tool_call=prep.tool_call, arg=partial))
def on_timer(elapsed_i: int, timeout_i: int):
# 🆕 bash 运行中每秒滴一次 → 前端气泡读秒 N/Ts
_emit(agent, AgentEvent(type="tool_execution_timer",
tool_call=prep.tool_call,
arg=(int(elapsed_i), int(timeout_i))))
result = execute_tool_call(prep, assistant, config, signal, on_update,
on_timer)
finalized = {"tool_call": prep.tool_call, "result": result,
"is_error": result.is_error}
_emit(agent, AgentEvent(type="tool_execution_end",
tool_call=prep.tool_call, result=result,
is_error=result.is_error))
return finalized
def _execute_parallel(current_context, assistant, config, signal, agent,
prepared: List[PreparedToolCall]) -> Dict[str, Any]:
"""对照 executeToolCallsParallelstart 事件串行发、准备串行、执行并发、
结果消息按原始顺序产出"""
finalized: List[Dict[str, Any]] = []
pending_futures: List = []
max_workers = max(1, min(8, len(prepared)))
with ThreadPoolExecutor(max_workers=max_workers,
thread_name_prefix="tool") as pool:
for prep in prepared:
_emit(agent, AgentEvent(type="tool_execution_start",
tool_call=prep.tool_call,
arg=str(prep.args) if prep.args else ""))
if prep.error:
# 准备失败(未知工具/参数非法)→ 立即结果(对照 kind:"immediate"
result = AgentToolResult.text(prep.error, is_error=True)
fin = {"tool_call": prep.tool_call, "result": result,
"is_error": True}
_emit(agent, AgentEvent(type="tool_execution_end",
tool_call=prep.tool_call, result=result,
is_error=True))
finalized.append(fin)
if signal.aborted:
break
continue
fut = pool.submit(_run_prepared_in_pool, prep, assistant, config,
signal, agent)
pending_futures.append(fut)
if signal.aborted:
# 已提交的任务仍会完成(它们内部检查 signal),不再追加
pass
for fut in pending_futures:
finalized.append(fut.result())
messages: List[AgentMessage] = []
for fin in finalized:
tr = _tool_result_message(fin)
_message_events(agent, tr)
messages.append(tr)
return {"messages": messages, "terminate": _should_terminate_batch(finalized)}
def _execute_sequential(current_context, assistant, config, signal, agent,
prepared: List[PreparedToolCall]) -> Dict[str, Any]:
"""对照 executeToolCallsSequential:一次一个,完成一个再下一个"""
finalized: List[Dict[str, Any]] = []
messages: List[AgentMessage] = []
for prep in prepared:
_emit(agent, AgentEvent(type="tool_execution_start",
tool_call=prep.tool_call,
arg=str(prep.args) if prep.args else ""))
if not prep.error:
fin = _run_prepared_in_pool(prep, assistant, config, signal, agent)
else:
result = AgentToolResult.text(prep.error, is_error=True)
fin = {"tool_call": prep.tool_call, "result": result, "is_error": True}
_emit(agent, AgentEvent(type="tool_execution_end",
tool_call=prep.tool_call, result=result,
is_error=True))
finalized.append(fin)
tr = _tool_result_message(fin)
_message_events(agent, tr)
messages.append(tr)
if signal.aborted:
break
return {"messages": messages, "terminate": _should_terminate_batch(finalized)}
def execute_tool_calls(current_context, assistant: AgentMessage,
config: AgentConfig, signal: AbortSignal, agent) -> Dict[str, Any]:
"""对照 executeToolCalls: 批次里有 sequential 工具 → 整批串行"""
prepared = prepare_tool_calls(assistant, config.tools)
has_sequential = any(
(p.tool and p.tool.execution_mode == "sequential") for p in prepared
if not p.error)
if config.tool_execution == "sequential" or has_sequential:
return _execute_sequential(current_context, assistant, config, signal,
agent, prepared)
return _execute_parallel(current_context, assistant, config, signal, agent,
prepared)
# ======================================================================
# 主循环 —— 对照 runLoop (行 163-278)
# ======================================================================
def run_loop(agent, new_message: Optional[AgentMessage], signal: AbortSignal,
stream_fn: Callable) -> RunResult:
config = agent.config
new_messages: List[AgentMessage] = []
# 入口校验(对照 runAgentLoopContinue 的前置检查由 Agent 层负责)
_emit(agent, AgentEvent(type="agent_start"))
_emit(agent, AgentEvent(type="turn_start")) # 首轮 turn_start(对照行 142/170
# context 准备 + transformContext 钩子
base = list(agent.state.messages)
if config.transform_context:
try:
base = config.transform_context(base) or base
except Exception:
pass
current_context = list(base)
first_turn = True
pending: List[AgentMessage] = agent._take_steering()
while True: # outer loop
has_more_tool_calls = True
while has_more_tool_calls or pending: # inner loop
if not first_turn:
_emit(agent, AgentEvent(type="turn_start"))
else:
first_turn = False
# ---- 注入 pending 消息(steering / followUp----
if pending:
for m in pending:
_message_events(agent, m)
current_context.append(m)
new_messages.append(m)
agent.state.messages.append(m)
pending = []
# ---- 🆕 轮中主动压缩检查(haocode 增强,偏离 pi 1:1----
# 单条工具输出可能把上下文顶出窗口;发下一次请求前主动检查
#(与轮首 should_compact 同公式)。compact_fn 返回新列表
#(发生了压缩)→ 同步循环局部上下文。
if config.compact_fn is not None and agent.state.messages:
try:
compacted = config.compact_fn(list(agent.state.messages))
if compacted is not None:
agent.state.messages = compacted
current_context = list(compacted)
except Exception:
pass
# ---- 流式生成助手消息 ----
assistant, error = _stream_turn(agent, current_context, config,
signal, stream_fn)
new_messages.append(assistant)
current_context.append(assistant)
if assistant.stop_reason in ("error", "aborted"):
agent.state.error = error
_emit(agent, AgentEvent(type="turn_end", message=assistant))
_emit(agent, AgentEvent(type="agent_end",
stop_reason=assistant.stop_reason,
error=error,
messages=new_messages))
agent._finish_run(new_messages, assistant.stop_reason, error)
return RunResult(stop_reason=assistant.stop_reason, error=error,
message_count=len(new_messages))
# ---- 工具调用 ----
tool_results: List[AgentMessage] = []
has_more_tool_calls = False
if assistant.tool_calls:
if assistant.stop_reason == "length":
# 🌟 截断保护(对照行 212-213):参数可能残缺,一律失败不执行
fail_msgs = fail_tool_calls_from_truncated_message(
assistant, "length")
# 事件流与正常执行对齐
for tc, tr in zip(assistant.tool_calls, fail_msgs):
_emit(agent, AgentEvent(type="tool_execution_start",
tool_call=tc))
res = AgentToolResult.text(
f'工具调用 "{tc.name}" 未执行:输出达到 token 上限,'
f'参数可能被截断。请用完整参数重新发起。', is_error=True)
_emit(agent, AgentEvent(type="tool_execution_end",
tool_call=tc, result=res,
is_error=True))
_message_events(agent, tr)
batch = {"messages": fail_msgs, "terminate": False}
else:
batch = execute_tool_calls(current_context, assistant, config,
signal, agent)
tool_results = batch["messages"]
has_more_tool_calls = not batch["terminate"]
for r in tool_results:
current_context.append(r)
new_messages.append(r)
agent.state.messages.append(r)
_emit(agent, AgentEvent(type="turn_end", message=assistant))
# ---- prepareNextTurn 钩子 ----
if config.prepare_next_turn:
try:
snap = config.prepare_next_turn({
"message": assistant, "tool_results": tool_results,
"context": current_context, "new_messages": new_messages,
})
if snap:
current_context = snap.get("context") or current_context
except Exception:
pass
# ---- shouldStopAfterTurn 钩子 ----
if config.should_stop_after_turn:
try:
if config.should_stop_after_turn({
"message": assistant, "tool_results": tool_results,
"context": current_context,
"new_messages": new_messages}):
stop = _last_assistant_stop(new_messages)
_emit(agent, AgentEvent(type="agent_end",
stop_reason=stop,
messages=new_messages))
agent._finish_run(new_messages, stop, None)
return RunResult(stop_reason=stop,
message_count=len(new_messages))
except Exception:
pass
# ---- 每轮结束取 steering ----
pending = agent._take_steering()
# ---- 外层:followUp ----
follow_ups = agent._take_follow_ups()
if follow_ups:
pending = follow_ups
continue
break
stop = _last_assistant_stop(new_messages)
_emit(agent, AgentEvent(type="agent_end", stop_reason=stop,
messages=new_messages))
agent._finish_run(new_messages, stop, None)
return RunResult(stop_reason=stop, message_count=len(new_messages))
def _last_assistant_stop(new_messages: List[AgentMessage]) -> str:
"""对照 pi: agent_end 不携带 stopReason,会话层从最后一条 assistant 消息读取"""
for m in reversed(new_messages):
if m.role == "assistant":
return m.stop_reason or "stop"
return "stop"
+519
View File
@@ -0,0 +1,519 @@
"""
core/agent/recovery.py
======================
🌟 pi 会话层恢复逻辑的 Python 1:1 移植
对照 pi-main 源码:
packages/coding-agent/src/core/agent-session.ts
_runAgentPrompt (行 1074): prompt → while _handlePostAgentRun(): continue()
_handlePostAgentRun (行 1088): 基于「最后一条 assistant 消息」的三路决策
_prepareRetry (行 2811): 计数 → 移除坏消息 → 退避睡眠
_isRetryableError (行 2770): 溢出交给压缩;其余走重试判定
_checkCompaction (行 ~2034): 溢出压缩恢复(只试一次)
packages/ai/src/utils/retry.ts
isRetryableAssistantError (行 223) + 可重试/不可重试正则模式表
退避: baseDelayMs × 2^(attempt-1)(默认 2s, 4s, 8smaxRetries 默认 3
packages/ai/src/utils/overflow.ts
isContextOverflow (行 134):三种溢出识别
isRecoverableLengthlength 且 output < 原始输出上限
🌟 决策表(与 pi 完全一致,基于最后一条 assistant 消息):
(1) stop_reason=="error"
a. 是上下文溢出 → 删除坏消息 + 压缩 + continue(只试一次)
b. 命中可重试模式(429/5xx/超时/断连…且非配额耗尽)
且未超 maxRetries → 删除坏消息 + 退避(2s×2^(n-1)) + continue
c. 其他 → 结束
(2) isRecoverableLengthlength 且实际输出 < 原始 maxTokens
说明是被上下文窗口挤断而非输出上限截断)且未尝试过
→ 删除该助手消息 + 压缩 + continue(只试一次)
(3) 队列里还有 followUp/steering → continue(续跑)
(4) 都不满足 → settle
"""
from __future__ import annotations
import os
import re
import time
from typing import Callable, Optional
from .compaction import CompactionSettings, _content_text, compact_context
from .context import estimate_context_tokens, should_compact
from .types import (AgentMessage, AgentConfig, RetryConfig, RunResult)
# ========== 压缩诊断日志(与 main_window.diag_log 同一规范:
# [HH:MM:SS.mmm] 时间戳 + 项目根目录追加 + UTF-8 + 失败静默) ==========
_COMPACT_LOG_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"compaction_diag.log")
def compact_diag_log(line: str):
"""压缩日志只记录:同一行内容 print(控制台)+ 追加写 compaction_diag.log"""
t = time.time()
stamp = (f"[{time.strftime('%H:%M:%S', time.localtime(t))}"
f".{int(t * 1000) % 1000:03d}]")
print(f"{stamp} {line}", flush=True)
try:
with open(_COMPACT_LOG_PATH, "a", encoding="utf-8") as f:
f.write(f"{stamp} {line}\n")
except Exception:
pass
def _settings_of(cfg: AgentConfig) -> CompactionSettings:
"""AgentConfig 压缩参数 → CompactionSettings1:1 映射 pi settings 字段)"""
return CompactionSettings(reserve_tokens=cfg.compaction_reserve,
keep_recent_tokens=cfg.compaction_keep_recent)
def _cut_ids_of(msgs: list, new_msgs: list) -> tuple:
"""🆕 压缩持久化切点:new_msgs = [摘要] + 保留尾巴(旧 msgs 的连续后缀)。
按 identity 定位尾巴起点 → (cut_before_id, first_retained_id)DB 行 id)。
⚠️ 行粒度:同一 DB 行(assistant timeline)会被 build_api_context 回放成
多条 API 消息(asst/tool 条目共享同一 _db_msg_id)。若尾巴起点落在某行
回放序列中间,必须回退到该行第一条,否则 cut_before 与 first_retained
会是同一行 → DB 自环(链死循环卡死)。
回退后下一轮会从该行完整重放(保守方向,宁多勿漏)。
任一为空 = 无法在 DB 链上切(如尾巴全是轮中内存消息)→ UI 不插标记。"""
if not new_msgs or getattr(new_msgs[0], "kind", None) != "compaction_summary":
return "", ""
tail = new_msgs[1:]
if not tail:
return "", ""
first = tail[0]
for i, m in enumerate(msgs):
if m is first:
# 回退到同一 DB 行回放序列的头部
fid = getattr(first, "db_msg_id", "") or ""
j = i
while j > 0 and fid and getattr(msgs[j - 1], "db_msg_id", "") == fid:
j -= 1
if j == 0:
return "", "" # 没有“之前”的行 → 无切点
return (getattr(msgs[j - 1], "db_msg_id", "") or "",
getattr(first, "db_msg_id", "") or "")
return "", "" # 尾巴首条不在旧列表(不应发生)→ 不插标记
# ======================================================================
# 对照 retry.ts 的模式表(原样移植)
# ======================================================================
_NON_RETRYABLE_LIMIT_RE = re.compile(
r"GoUsageLimitError|FreeUsageLimitError|"
r"Monthly usage limit reached|available balance|"
r"insufficient_quota|out of budget|quota exceeded|billing",
re.IGNORECASE,
)
_RETRYABLE_RE = re.compile(
r"overloaded|rate.?limit|too many requests|429|500|502|503|504|524|"
r"service.?unavailable|server.?error|internal.?error|"
r"provider.?returned.?error|exceeded request buffer limit while retrying upstream|"
r"network.?error|connection.?error|connection.?refused|connection.?lost|"
r"other side closed|fetch failed|getaddrinfo|ENOTFOUND|EAI_AGAIN|"
r"upstream.?connect|reset before headers|socket hang up|timeout|timed?\s?out",
re.IGNORECASE,
)
# 对照 overflow.ts OVERFLOW_PATTERNS(各服务商话术)
_OVERFLOW_RE = re.compile(
r"context\s*window|prompt is too long|maximum context|too many tokens|"
r"context length|exceeds the (model|maximum) context|"
r"maximum context length is \d+ tokens|exceeds the maximum allowed input length|"
r"longer than the model's context length|exceeds the available context size|"
r"greater than the context length|exceeded model token limit|"
r"Range of input length should be|configured context size|"
r"长于|超出.*上下文|上下文.*(超出|超过|不足)",
re.IGNORECASE,
)
# 限速类不是溢出(对照 overflow.ts NON_OVERFLOW_PATTERNS 的判定意图)
_NON_OVERFLOW_RE = re.compile(
r"rate.?limit|too many requests|429|overloaded", re.IGNORECASE)
# ======================================================================
# 判定函数(对照 pi overflow.ts / retry.ts,基于 assistant 消息)
# ======================================================================
def is_context_overflow(message: AgentMessage,
context_window: int = 0) -> bool:
"""
对照 isContextOverflow 的三种情形:
1. error + 消息文本命中溢出模式(且不是限速类)
2. 静默溢出:stop 但 usage.input > contextWindowz.ai 风格)
3. 服务端截断式溢出:length 且 output==0 且 input ≥ 0.99×窗口
"""
if message.role != "assistant":
return False
if message.stop_reason == "error" and message.error_message:
if not _NON_OVERFLOW_RE.search(message.error_message) \
and _OVERFLOW_RE.search(message.error_message):
return True
usage = message.usage or {}
input_tokens = int(usage.get("input", 0) or 0) + int(usage.get("cacheRead", 0) or 0)
if context_window and message.stop_reason == "stop":
if input_tokens > context_window:
return True
if context_window and message.stop_reason == "length" \
and int(usage.get("output", 0) or 0) == 0:
if input_tokens >= context_window * 0.99:
return True
return False
def is_retryable_assistant_error(message: AgentMessage) -> bool:
"""对照 isRetryableAssistantError:基于 errorMessage 文本模式"""
if message.role != "assistant" or message.stop_reason != "error":
return False
if not message.error_message:
return False
if _NON_RETRYABLE_LIMIT_RE.search(message.error_message):
return False
return bool(_RETRYABLE_RE.search(message.error_message))
def is_recoverable_length(message: AgentMessage,
desired_max_output: int) -> bool:
"""
对照 pi isRecoverableLength
length 停止 且 实际输出 < 「原始输出上限」(钳制前的 maxTokens)。
"""
if message.role != "assistant" or message.stop_reason != "length":
return False
if desired_max_output <= 0:
return False
out = int((message.usage or {}).get("output", 0) or 0)
return out < desired_max_output
def compute_retry_delay_ms(attempt: int, cfg: RetryConfig) -> float:
"""对照 pi _prepareRetry: baseDelayMs × 2^(attempt-1)(无抖动、默认无封顶)"""
delay = cfg.base_delay_ms * (cfg.factor ** (attempt - 1))
if cfg.max_delay_ms and delay > cfg.max_delay_ms:
delay = cfg.max_delay_ms
return float(delay)
def find_last_assistant(messages) -> Optional[AgentMessage]:
"""对照 agent-session _findLastAssistantMessage"""
for m in reversed(messages):
if m.role == "assistant":
return m
return None
# ======================================================================
# 编排器 —— 对照 agent-session 的 run 循环
# ======================================================================
class AgentRunner:
"""
对照 agent-session.ts 的 run 编排(agent 之外的「会话层」)。
haocode 里由 AgentWorker(QThread) 持有一个 AgentRunner。
summarize_fn(transcript) -> str:压缩用的非流式 LLM 调用(缺省 → 禁压缩)。
"""
def __init__(self, agent: Agent,
summarize_fn: Optional[Callable[[str], str]] = None,
on_retry_scheduled: Optional[Callable] = None,
on_retry_finished: Optional[Callable] = None,
on_compaction_started: Optional[Callable] = None,
on_compaction_finished: Optional[Callable] = None):
self.agent = agent
self.summarize_fn = summarize_fn
# 🆕 M3: 重试回调(对照 pi retryAssistantCall 的
# onRetryScheduled / onRetryFinished)——UI 可提示「正在重试」
self.on_retry_scheduled = on_retry_scheduled
self.on_retry_finished = on_retry_finished
# 🆕 压缩开始回调(摘要 LLM 调用阻塞前触发)——UI 显示「执行中」动态气泡
self.on_compaction_started = on_compaction_started
# 🆕 压缩完成回调(压缩结束瞬间触发,不等整轮 run 结束)
# ——前端气泡即时定格「已完成上下文压缩」(与思考块定格同款时机)
self.on_compaction_finished = on_compaction_finished
self._overflow_recovered = False # 对照 _overflowRecoveryAttempted
self._retry_attempt = 0 # 对照 _retryAttempt
self.last_action = "none" # 诊断: none/retry/overflow_compact/length_compact/queued
self.compactions_performed = 0 # 成功压缩次数(UI 提示用)
# 🆕 压缩事件队列(UI 气泡用:摘要全文/前后 token/耗时/路径)
self.compaction_events: list = []
# 🆕 轮中主动压缩护栏(同一 run 内;AgentRunner 每轮新建 → 自动复位)
self._mid_turn_fail_streak = 0
self._mid_turn_exhausted = False
# ------------------------------------------------------------------
# 发送前压缩(🌟 1:1 对照 pi shouldCompacttokens > window - reserveTokens
# ------------------------------------------------------------------
def pre_prompt_compaction(self) -> bool:
if self.summarize_fn is None:
return False
cfg = self.agent.config
msgs = self.agent.state.messages
m = cfg.model
# 🆕 G2: 透传 system_prompt/tools —— 无锚点时度量「下一请求真实载荷」
should, tokens = should_compact(msgs, m, cfg.compaction_reserve,
system_prompt=cfg.system_prompt,
tools=cfg.tools)
# 🆕 每次请求前的校验:print + 写 compaction_diag.log(触发与否都记)
compact_diag_log(
f"[PRE_CHECK] model={m.name} window={m.context_window} "
f"reserve={cfg.compaction_reserve} "
f"threshold={m.context_window - cfg.compaction_reserve} "
f"est={tokens} triggered={str(should).lower()}")
if not should:
return False
self._notify_compaction_started("pre_prompt", tokens)
t0 = time.time()
new_msgs = compact_context(msgs, cfg.model, self.summarize_fn,
settings=_settings_of(cfg))
if new_msgs is not None:
cut_before, first_retained = _cut_ids_of(msgs, new_msgs)
self.agent.state.messages = new_msgs
self.compactions_performed += 1
self._record_compaction("pre_prompt", tokens, new_msgs, t0,
cut_before_id=cut_before,
first_retained_id=first_retained)
return True
self._record_compaction_failed("pre_prompt", tokens, t0)
return False
# ------------------------------------------------------------------
# 🆕 轮中主动压缩(haocode 增强,偏离 pi 1:1
# ------------------------------------------------------------------
def compact_if_needed(self, msgs: list) -> Optional[list]:
"""接线到 AgentConfig.compact_fn:内层循环每次 LLM 请求前调用。
与轮首 pre_prompt_compaction 同公式(tokens > window reserve)。
轮中单条工具输出可把上下文顶出窗口(轮中内存工具结果没有
4000 字回放上限),发请求前主动压缩,避免「一次失败往返 +
响应式兜底」。返回新消息列表(发生了压缩)或 None(不需要/
不可压缩 → 原样发请求,响应式 overflow 安全网仍在)。
防刷屏护栏:同一 run 内连续失败 2 次后不再尝试(形状无有效
切点,如单条巨型条目,硬试无意义);成功则复位计数。
"""
if self.summarize_fn is None or self._mid_turn_exhausted:
return None
cfg = self.agent.config
m = cfg.model
# 🆕 G2: 透传 system_prompt/tools(与轮首同口径)
should, tokens = should_compact(msgs, m, cfg.compaction_reserve,
system_prompt=cfg.system_prompt,
tools=cfg.tools)
compact_diag_log(
f"[PRE_CHECK] path=mid_turn est={tokens} "
f"threshold={m.context_window - cfg.compaction_reserve} "
f"triggered={str(should).lower()}")
if not should:
return None
if self._do_compaction("mid_turn", msgs=msgs):
self._mid_turn_fail_streak = 0
return list(self.agent.state.messages)
self._mid_turn_fail_streak += 1
if self._mid_turn_fail_streak >= 2:
self._mid_turn_exhausted = True
compact_diag_log(
"[PRE_CHECK] path=mid_turn exhausted "
"(连续 2 次失败,本轮不再尝试)")
return None
# ------------------------------------------------------------------
# 主入口 —— 对照 _runAgentPrompt
# ------------------------------------------------------------------
def run(self, message) -> RunResult:
self.pre_prompt_compaction()
self._overflow_recovered = False
self._retry_attempt = 0
result = self.agent.prompt(message)
return self._post_loop(result)
def _post_loop(self, result: RunResult) -> RunResult:
# 对照: while (await this._handlePostAgentRun()) await this.agent.continue()
while True:
action = self._handle_post_agent_run(result)
if action == "stop":
return result
if action == "retry":
result = self.agent.continue_()
# 🆕 M3: 重试结果回调(成功 = 下一轮未以 error/aborted 收尾)
if self.on_retry_finished is not None:
la = find_last_assistant(self.agent.state.messages)
ok = (la is not None
and la.stop_reason not in ("error", "aborted"))
try:
self.on_retry_finished(bool(ok))
except Exception:
pass
continue
if action == "compact_retry":
if not (self._remove_last_bad_assistant()
and self._do_compaction(self.last_action)):
return result # 压缩不可用 → 放弃,交 UI 报错
result = self.agent.continue_()
continue
if action == "queued":
result = self.agent.continue_()
continue
return result
# ------------------------------------------------------------------
# 三路决策 —— 对照 _handlePostAgentRun
# ------------------------------------------------------------------
def _handle_post_agent_run(self, result: RunResult) -> str:
msg = find_last_assistant(self.agent.state.messages)
if msg is None:
return "stop"
cfg = self.agent.config
# (1) 错误路径
if msg.stop_reason == "error":
# a. 溢出 → 压缩恢复(只试一次)
if is_context_overflow(msg, cfg.model.context_window) \
and not self._overflow_recovered \
and self.summarize_fn is not None:
self._overflow_recovered = True
self.last_action = "overflow_compact"
return "compact_retry"
# b. 可重试错误 → 移除坏消息 + 退避 + continue
if is_retryable_assistant_error(msg) \
and self._prepare_retry(cfg.retry,
reason=msg.error_message or ""):
self.last_action = "retry"
return "retry"
return "stop"
# (2) 可恢复的 length 截断
if is_recoverable_length(msg, cfg.model.max_tokens) \
and not self._overflow_recovered \
and self.summarize_fn is not None:
self._overflow_recovered = True
self.last_action = "length_compact"
return "compact_retry"
# (3) 队列里还有消息 → 续跑
if self.agent.has_queued():
self.last_action = "queued"
return "queued"
# (4) settle
return "stop"
# ------------------------------------------------------------------
# 对照 _prepareRetry:计数 → 移除坏消息 → 退避睡眠
# ------------------------------------------------------------------
def _prepare_retry(self, cfg: RetryConfig, reason: str = "") -> bool:
if self._retry_attempt >= cfg.max_attempts:
return False
self._retry_attempt += 1
delay_ms = compute_retry_delay_ms(self._retry_attempt, cfg)
# 移除错误助手消息(对照: messages.slice(0,-1),会话历史保留由 UI 层负责)
messages = self.agent.state.messages
if messages and messages[-1].role == "assistant":
self.agent.state.messages = messages[:-1]
# 🆕 M3: 通知重试已调度(参数: attempt, max_attempts, delay_ms, reason
if self.on_retry_scheduled is not None:
try:
self.on_retry_scheduled(self._retry_attempt, cfg.max_attempts,
delay_ms, reason)
except Exception:
pass
time.sleep(delay_ms / 1000.0)
return True
# ------------------------------------------------------------------
# 压缩恢复辅助
# ------------------------------------------------------------------
def _remove_last_bad_assistant(self) -> bool:
msgs = self.agent.state.messages
if msgs and msgs[-1].role == "assistant" \
and msgs[-1].stop_reason in ("length", "error"):
self.agent.state.messages = msgs[:-1]
return True
return False
def _do_compaction(self, path: str = "reactive",
msgs: Optional[list] = None) -> bool:
if self.summarize_fn is None:
return False
cfg = self.agent.config
if msgs is None:
msgs = self.agent.state.messages
tokens_before = estimate_context_tokens(msgs).tokens
self._notify_compaction_started(path, tokens_before)
t0 = time.time()
new_msgs = compact_context(msgs, cfg.model, self.summarize_fn,
settings=_settings_of(cfg))
if new_msgs is not None:
cut_before, first_retained = _cut_ids_of(msgs, new_msgs)
self.agent.state.messages = new_msgs
self.compactions_performed += 1
self._record_compaction(path, tokens_before, new_msgs, t0,
cut_before_id=cut_before,
first_retained_id=first_retained)
return True
self._record_compaction_failed(path, tokens_before, t0)
return False
# ------------------------------------------------------------------
# 🆕 压缩可视化辅助:开始/完成 两阶段(print + 日志 + 事件供 UI 气泡)
# ------------------------------------------------------------------
def _notify_compaction_started(self, path: str, tokens_before: int) -> None:
compact_diag_log(f"[COMPACT_START] path={path} before={tokens_before}")
if self.on_compaction_started is not None:
try:
self.on_compaction_started(path)
except Exception:
pass
def _record_compaction_failed(self, path: str, tokens_before: int,
t0: float) -> None:
"""压缩不可行(如历史无法切分)→ 气泡显示失败原因,不能永远卡在「执行中」"""
duration_ms = int((time.time() - t0) * 1000)
compact_diag_log(
f"[COMPACT_FAIL] path={path} before={tokens_before} "
f"duration_ms={duration_ms}")
ev = {
"summary": "本次压缩未能执行(历史无法切分),继续以原有历史对话。",
"before": tokens_before,
"after": tokens_before,
"duration_ms": duration_ms,
"path": path,
"failed": True,
}
self.compaction_events.append(ev)
if self.on_compaction_finished is not None:
try:
self.on_compaction_finished(dict(ev))
except Exception:
pass
def _record_compaction(self, path: str, tokens_before: int,
new_msgs: list, t0: float,
cut_before_id: str = "",
first_retained_id: str = "") -> None:
summary = ""
if new_msgs and getattr(new_msgs[0], "kind", None) == "compaction_summary":
summary = _content_text(new_msgs[0].content) or ""
tokens_after = estimate_context_tokens(new_msgs).tokens
duration_ms = int((time.time() - t0) * 1000)
compact_diag_log(
f"[COMPACT_DONE] path={path} before={tokens_before} "
f"after={tokens_after} summary_chars={len(summary)} "
f"duration_ms={duration_ms} "
f"cut_before={cut_before_id or '-'} first_retained={first_retained_id or '-'}")
ev = {
"summary": summary,
"before": tokens_before,
"after": tokens_after,
"duration_ms": duration_ms,
"path": path,
# 🆕 压缩持久化切点:UI 收到后在 DB 链上插标记行(两者齐备才插)
"cut_before_id": cut_before_id,
"first_retained_id": first_retained_id,
}
self.compaction_events.append(ev)
if self.on_compaction_finished is not None:
try:
self.on_compaction_finished(dict(ev))
except Exception:
pass
+438
View File
@@ -0,0 +1,438 @@
"""
core/agent/stream_fn.py
=======================
🌟 pi-ai 流式接口的 Python 1:1 移植 —— OpenAI 兼容(含 vLLM
对照 pi-main 源码:
packages/ai/src/api/openai-completions.ts
- streamSimple() (行 469 起): 消费 SSE chunk → 产出 AssistantMessage
- buildParams() (行 536 起): 组装请求体
* vLLM/OpenAI 默认字段名 = max_completion_tokens
* 少数服务商(chutes/deepseek/moonshot 等)用 max_tokens
- mapStopReason(): finish_reason "length" → stopReason "length"
packages/ai/src/api/simple-options.ts
- buildBaseOptions(): maxTokens = options?.maxTokens ?? model.maxTokens
(即模型定义里的 maxTokens 一定会进入请求体,这就是它被发给 vLLM 的原因)
本文件职责:
1. to_openai_messages() —— pi 消息格式 → OpenAI API 格式(每轮调用前转换)
2. openai_stream() —— 流式请求,逐 chunk 产出 AssistantMessageEvent
结束产出最终 AgentMessage(含 usage / stop_reason / tool_calls
3. classify_error() —— 异常分类(对照 pi isRetryable 的输入)
🌟 修复 haocode 原有 P1 bugopenai 客户端带 timeout(原来无超时,
挂死的流会让线程永久阻塞)。max_retries=0(重试统一交给 recovery 层,
与 pi 一致:网络层不重试,会话层按 1600ms×1.6^n 退避重试)。
"""
from __future__ import annotations
import json
from typing import Any, Dict, Iterator, List, Optional, Tuple
from openai import OpenAI
from openai import APIConnectionError, APIStatusError, APITimeoutError, RateLimitError
from .types import (AgentError, AgentMessage, AssistantMessageEvent,
AbortSignal, ModelConfig, ToolCall, new_id)
# ======================================================================
# pi 消息 → OpenAI 消息(每轮发送前调用)
# ======================================================================
def to_openai_messages(context: List[AgentMessage],
pass_reasoning: bool = True) -> List[Dict[str, Any]]:
"""pi 消息 → OpenAI chat 格式。
🆕 thinking 模式(DeepSeek 等):assistant 消息必须把 reasoning_content
原样回传,否则服务端 400
'The reasoning_content in the thinking mode must be passed back to the API')。
默认开启:轮中续问/工具回灌场景,内存里本轮刚流式产出的 assistant 带
reasoning → 回传;DB 回放的旧行 reasoning=""(不入库)→ 无该字段,
跨轮全新请求不受影响。pass_reasoning=False 显式关闭(特殊网关逃生阀)。
"""
out: List[Dict[str, Any]] = []
for m in context:
if m.role == "system":
# 只放行请求头部的系统提示词(loop 注入);
# 历史中间出现的 system(DB 防御)仍然跳过
if not out:
out.append({"role": "system", "content": m.content})
continue
if m.role == "toolResult":
out.append({
"role": "tool",
"tool_call_id": m.tool_call_id,
"content": m.content if isinstance(m.content, str)
else "".join(c.get("text", "") for c in m.content
if isinstance(c, dict)),
})
continue
if m.role == "assistant":
entry: Dict[str, Any] = {"role": "assistant"}
# 纯工具调用轮:content 可能为空 → 用 ""vLLM 接受,避免 null 报错)
entry["content"] = m.content if m.content else ""
if m.tool_calls:
entry["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False)
if tc.arguments else tc.raw_arguments or "{}",
},
}
for tc in m.tool_calls
]
if pass_reasoning and m.reasoning:
entry["reasoning_content"] = m.reasoning
out.append(entry)
continue
# usercontent 可以是 str 或 OpenAI 多模态 list,原样透传)
out.append({"role": "user", "content": m.content})
return out
# ======================================================================
# 异常分类 —— 对照 pi isRetryable / retryable error 判定
# ======================================================================
def classify_error(e: Exception) -> AgentError:
"""把 openai SDK 异常分类,恢复层据此决定重试/放弃。"""
if isinstance(e, RateLimitError):
return AgentError(message=str(e), kind="rate_limit",
status_code=429, recoverable=True)
if isinstance(e, APITimeoutError):
return AgentError(message=f"请求超时: {e}", kind="timeout", recoverable=True)
if isinstance(e, APIConnectionError):
return AgentError(message=f"连接失败: {e}", kind="connection", recoverable=True)
if isinstance(e, APIStatusError):
code = e.status_code
msg = str(e)
if code in (502, 503, 504):
return AgentError(message=msg, kind="server_error",
status_code=code, recoverable=True)
if code == 529:
return AgentError(message=msg, kind="overload",
status_code=code, recoverable=True)
if code == 408:
return AgentError(message=msg, kind="timeout",
status_code=code, recoverable=True)
if code in (401, 403):
return AgentError(message=msg, kind="auth",
status_code=code, recoverable=False)
# 4xx(参数错误/模型不存在/上下文超长等)不可重试,交恢复层细分
return AgentError(message=msg, kind="unknown",
status_code=code, recoverable=False)
if isinstance(e, (ConnectionError, TimeoutError)):
return AgentError(message=str(e), kind="connection", recoverable=True)
return AgentError(message=str(e), kind="unknown", recoverable=False)
# ======================================================================
# 🆕 M1/M2: 流式字段提取(对照 pi openai-completions.ts 的字段优先级)
# ======================================================================
# 🆕 M1: 1:1 对照 pi chunk 级 reasoning 字段优先级(llama.cpp/vLLM/OpenRouter 各不同)
_REASONING_FIELDS = ("reasoning_content", "reasoning", "reasoning_text")
def _pick_reasoning(delta) -> str:
"""从 delta 上取第一个非空思考字段(只取一个,防同内容多字段重复计)"""
if delta is None:
return ""
dump = None
for name in _REASONING_FIELDS:
v = getattr(delta, name, None)
if v is None and dump is None and hasattr(delta, "model_dump"):
try:
dump = delta.model_dump()
except Exception:
dump = {}
if v is None and isinstance(dump, dict):
v = dump.get(name)
if v:
return str(v)
return ""
def _pick_usage(chunk):
"""🆕 M2: 对照 pi:先 chunk.usage,再 choices[0].usageMoonshot 系只放 choice 里)"""
u = getattr(chunk, "usage", None)
if u is not None:
return u
try:
choices = getattr(chunk, "choices", None)
if choices:
return getattr(choices[0], "usage", None)
except Exception:
pass
return None
# ======================================================================
# 流式请求核心 —— 对照 openai-completions.ts streamSimple
# ======================================================================
def openai_stream(context: List[AgentMessage],
model: ModelConfig,
signal: AbortSignal,
max_tokens: int,
tools: Optional[List[Any]] = None,
) -> Iterator[Tuple[str, Any]]:
"""
生成器协议(对照 pi streamSimple 的 for-await 消费方式):
yield ("event", AssistantMessageEvent) # 增量事件(text/thinking/toolcall/done
yield ("final", AgentMessage) # 最终助手消息(含 stop_reason/usage
异常统一转 AgentError 抛出(由循环层捕获 → stop_reason="error")。
🌟 中止语义与 pi 一致:每个 chunk 边界检查 signal.aborted
命中即关闭流、以 stop_reason="aborted" 收尾(不是 error)。
"""
client = OpenAI(
api_key=model.api_key or "EMPTY",
base_url=model.base_url,
# 🌟 P1 修复:显式超时(pi 侧由 fetch timeout 保证)
timeout=model.timeout_seconds,
max_retries=0, # 重试统一由 recovery 层负责
)
params: Dict[str, Any] = {
"model": model.name,
"messages": to_openai_messages(context, model.pass_reasoning),
"stream": True,
"stream_options": {"include_usage": True}, # 拿真实 usagepi 同款)
"temperature": model.temperature,
}
# 对照 pi buildParams:默认 max_completion_tokensvLLM 兼容)
if getattr(model, "use_max_tokens_field", False):
params["max_tokens"] = max_tokens
else:
params["max_completion_tokens"] = max_tokens
# 🌟 工具定义(对照 pi buildParams 的 tools 段)——不发给模型,
# 模型就不可能发出真正的 tool_call,只会用文字"演"工具调用!
if tools:
params["tools"] = [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
},
}
for t in tools
]
# 累积状态
content_parts: List[str] = []
reasoning_parts: List[str] = []
tc_acc: Dict[int, Dict[str, Any]] = {} # index -> {id, name, args}
finish_reason: Optional[str] = None
usage: Dict[str, Any] = {}
stream = None
try:
stream = client.chat.completions.create(**params)
for chunk in stream:
# 🌟 pi 同款:chunk 边界检查中止
if signal.aborted:
finish_reason = "aborted"
try:
stream.close()
except Exception:
pass
break
# 🆕 M2: usage 读取顺序对照 pi —— chunk.usage 优先,choice.usage 兜底
usage_obj = _pick_usage(chunk)
if usage_obj is not None:
usage = {
"input": getattr(usage_obj, "prompt_tokens", 0) or 0,
"output": getattr(usage_obj, "completion_tokens", 0) or 0,
"cacheRead": 0, "cacheWrite": 0,
}
if not getattr(chunk, "choices", None):
continue
choice = chunk.choices[0]
delta = choice.delta
# 🆕 M1: 思考增量(对照 pi reasoningFields 优先级,含多字段去重)
reasoning = _pick_reasoning(delta)
if reasoning:
yield ("event", AssistantMessageEvent.thinking_delta(reasoning))
reasoning_parts.append(reasoning)
if getattr(delta, "content", None):
yield ("event", AssistantMessageEvent.text_delta(delta.content))
content_parts.append(delta.content)
# 工具调用增量(按 index 累积 id/name/arguments 三段)
for tc_delta in (getattr(delta, "tool_calls", None) or []):
idx = tc_delta.index or 0
slot = tc_acc.setdefault(idx, {"id": "", "name": "", "args": ""})
if tc_delta.id:
slot["id"] = tc_delta.id
yield ("event", AssistantMessageEvent.toolcall_delta(
idx, tc_delta.id, field="id"))
fn = tc_delta.function
if fn is not None:
if fn.name:
slot["name"] += fn.name
yield ("event", AssistantMessageEvent.toolcall_delta(
idx, fn.name, field="name"))
if fn.arguments:
slot["args"] += fn.arguments
yield ("event", AssistantMessageEvent.toolcall_delta(
idx, fn.arguments, field="arguments"))
if choice.finish_reason:
finish_reason = choice.finish_reason
except Exception as e:
# 中止过程中断网/断流不当错误处理(pi 同款语义)
if signal.aborted:
finish_reason = "aborted"
else:
raise classify_error(e)
finally:
try:
if stream is not None:
stream.close()
except Exception:
pass
# ---- 组装最终助手消息(对照 mapStopReason + AssistantMessage 构造)----
# pi mapStopReason: "length"→"length", "stop"/"tool_calls"→"stop", 其余→"stop"
if finish_reason == "aborted":
stop_reason = "aborted"
elif finish_reason == "length":
stop_reason = "length"
else:
stop_reason = "stop"
tool_calls: List[ToolCall] = []
for idx in sorted(tc_acc.keys()):
slot = tc_acc[idx]
try:
args = json.loads(slot["args"]) if slot["args"] else {}
except json.JSONDecodeError:
# 参数 JSON 被截断/损坏:保留 rawarguments 置空,
# 由循环层按「截断保护」路径处理(不执行残缺调用)
args = {}
tool_calls.append(ToolCall(
id=slot["id"] or new_id("call"),
name=slot["name"],
arguments=args,
raw_arguments=slot["args"],
))
final = AgentMessage(
role="assistant",
content="".join(content_parts),
reasoning="".join(reasoning_parts),
tool_calls=tool_calls,
stop_reason=stop_reason,
usage=usage,
)
yield ("event", AssistantMessageEvent.done())
yield ("final", final)
# ======================================================================
# OpenAI 格式 → pi 格式(from_openai_messages 的逆转换,用于从 DB 装载历史)
# ======================================================================
def _parse_tool_call(tc: Dict[str, Any]) -> Optional[ToolCall]:
"""
解析一条 OpenAI tool_call → ToolCall。
🌟 参数 JSON 可能残缺(历史里存了被截断的调用):
解析失败 → arguments={} 且 raw_arguments 兜底成合法 "{}"
保证 to_openai_messages 回发 API 时参数始终是合法 JSON
API 只校验 JSON 合法性 + tool_call_id 配对,不校验参数内容)。
"""
if not isinstance(tc, dict):
return None
fn = tc.get("function") or {}
raw_args = fn.get("arguments")
if not isinstance(raw_args, str):
raw_args = "" if raw_args is None else str(raw_args)
try:
args = json.loads(raw_args) if raw_args else {}
if not isinstance(args, dict):
args = {"_": args}
except (json.JSONDecodeError, TypeError, ValueError):
args = {}
raw_args = "{}" # 残缺 JSON 兜底,防回发 API 时 400
return ToolCall(
id=tc.get("id") or new_id("call"),
name=fn.get("name") or "",
arguments=args,
raw_arguments=raw_args or "{}",
)
def from_openai_messages(messages: List[Dict[str, Any]]) -> List[AgentMessage]:
"""
把 OpenAI chat 消息数组转成 pi 的 AgentMessage 列表(全保真)。
🌟 修复:worker 模式此前在此丢失全部工具历史(tool 消息被跳过、
assistant.tool_calls 不解析)→ 任务完成/中断后再次提问,模型看不到
之前读过什么文件、执行过什么命令。现改为无损转换:
- user: content 保持 str 或 OpenAI 多模态 list(原样透传)
- assistant: content + reasoning + tool_calls(纯工具轮保留 tool_calls
content 置 "",不再变成空 assistant 污染上下文)
- tool: → AgentMessage(role="toolResult", tool_call_id, content)
- system: 防御性跳过(DB 链表里不会出现)
下游 to_openai_messages 原生支持 toolResult / assistant.tool_calls
与 pi 语义一致,往返(to(from(x)))保真。
"""
out: List[AgentMessage] = []
for m in messages:
if not isinstance(m, dict):
continue
role = m.get("role")
content = m.get("content")
if role == "user":
if content is None:
content = ""
out.append(AgentMessage(role="user", content=content,
# 🆕 压缩持久化:跨轮携带 DB 行 id + 摘要 kind
# kind=compaction_summary → 二次压缩走迭代更新)
kind=m.get("_kind") or "",
db_msg_id=m.get("_db_msg_id") or "",
timestamp=int(m.get("timestamp") or 0)))
elif role == "assistant":
# tool_calls 解析(容忍缺失/损坏)
tool_calls: List[ToolCall] = []
for tc in (m.get("tool_calls") or []):
parsed = _parse_tool_call(tc)
if parsed is not None:
tool_calls.append(parsed)
out.append(AgentMessage(
role="assistant",
content=content if isinstance(content, str) else "",
reasoning=m.get("reasoning") or "",
tool_calls=tool_calls,
stop_reason="stop",
# 🆕 P1: 回放入库的 usage(锚点)+ timestampP0 时效校验)
usage=m.get("usage") or {},
timestamp=int(m.get("timestamp") or 0),
db_msg_id=m.get("_db_msg_id") or "",
))
elif role == "tool":
# tool 结果 → toolResult(保留 tool_call_id 供 API 配对)
if content is None:
content = ""
elif not isinstance(content, str):
content = str(content)
out.append(AgentMessage(
role="toolResult",
content=content,
tool_call_id=m.get("tool_call_id") or "",
timestamp=int(m.get("timestamp") or 0),
db_msg_id=m.get("_db_msg_id") or "",
))
# 其他 rolesystem 等)防御性跳过
return out
+873
View File
@@ -0,0 +1,873 @@
"""
core/agent/tools.py
===================
🌟 pi 工具执行管线的 Python 1:1 移植 + 内置工具
对照 pi-main 源码:
packages/agent/src/agent-loop.ts
- prepareToolCalls() (行 ~470): 校验参数 → 标记错误(不执行)
- executeTool() (行 ~520): before 钩子 → 执行 → after 钩子 → 结果定型
- 并行语义: prepare 串行 → 执行并发(Promise.all) → 结果按原始顺序回写
packages/agent/src/tools/*.ts (coding-agent 内置工具 read/bash/edit/write)
JSON Schema 校验:pi 用 validate-json-schema + ai/src/utils/validation.ts
structuredClone → normalizeOptionalNulls → Value.Convert → coerceWithJsonSchema →
全量错误上报 + 回显收到的参数);这里实现核心子集
type/required/properties/enum/items + 可选字段 null 归一化 + 数字/布尔轻量转换),
零外部依赖。工具自定义预处理对照 pi 的 tool.prepareArguments(见 edit 的 legacy 兼容)。
"""
from __future__ import annotations
import json
import os
import queue
import re
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from .types import (AgentMessage, AgentTool, AgentToolResult, AbortSignal,
ToolCall, new_id)
try:
from core.debug_log import debug_log as _dbg_log # 🆕 计时观察日志(线程安全/静默)
except Exception: # 导入失败也不影响工具执行
def _dbg_log(msg, tag="APP"):
pass
# ======================================================================
# JSON Schema 校验(子集)
# ======================================================================
# ======================================================================
# 参数归一化 + JSON Schema 校验(子集)
# 对照 pi ai/src/utils/validation.ts:317-347 validateToolArguments
# structuredClone → normalizeOptionalNulls → Value.Convert
# → coerceWithJsonSchema → Check → 【报全部错误 + 回显收到的参数】
# 这里实现核心子集(type/required/properties/enum/items),零外部依赖。
# ======================================================================
_TYPE_MAP = {
"string": str,
"integer": int,
"number": (int, float),
"boolean": bool,
"array": list,
"object": dict,
}
def _norm_types(schema: Dict[str, Any]) -> List[str]:
"""schema 声明的类型(兼容 TypeBox 的 type 数组形式)"""
t = schema.get("type")
if isinstance(t, list):
return [x for x in t if isinstance(x, str)]
return [t] if isinstance(t, str) else []
def _type_ok(value: Any, t: str) -> bool:
"""类型匹配。🌟 bool 不算 integer/numberpython 里 bool 是 int 子类)"""
if t == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if t == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if t == "boolean":
return isinstance(value, bool)
py = _TYPE_MAP.get(t)
return isinstance(value, py) if py else True
def _join_path(path: str, key: str) -> str:
return f"{path}.{key}" if path else key
def normalize_and_coerce(args: Any, schema: Dict[str, Any]) -> Any:
"""归一化 + 轻量强制转换(对照 pi normalizeOptionalNulls + coerceWithJsonSchema
- 可选字段的显式 null → 删除该键(模型常对「未用到的参数」发 null)
- 数字字段收到纯数字字符串 → integer/number"30" → 30
- boolean 字段收到 "true"/"false" → 布尔
返回新对象,不修改入参。
"""
if not isinstance(schema, dict) or not isinstance(args, dict):
return args
props = schema.get("properties") or {}
required = set(schema.get("required") or [])
out: Dict[str, Any] = dict(args)
for key, val in list(out.items()):
sub = props.get(key)
if not isinstance(sub, dict):
continue
if val is None:
if key not in required:
del out[key]
continue
out[key] = _coerce_value(val, sub)
return out
def _coerce_value(val: Any, sub: Dict[str, Any]) -> Any:
types = _norm_types(sub)
if isinstance(val, bool) or val is None:
return val
if isinstance(val, str):
s = val.strip()
if s and ("integer" in types or "number" in types):
try:
f = float(s)
if f.is_integer():
return int(f)
if "number" in types:
return f
return val # integer 字段收到 1.5 → 保持原值(交由校验报错)
except ValueError:
return val
if "boolean" in types and s.lower() in ("true", "false"):
return s.lower() == "true"
return val
if isinstance(val, dict):
return normalize_and_coerce(val, sub)
if isinstance(val, list):
items = sub.get("items")
if isinstance(items, dict):
return [_coerce_value(v, items) if not isinstance(v, dict)
else normalize_and_coerce(v, items) for v in val]
return val
def _type_error(path: str, types: List[str], value: Any) -> str:
t = types[0] if types else "object"
if t == "string":
return f"参数 {path} 应为 string"
if t in ("integer", "number") and isinstance(value, bool):
return f"参数 {path} 应为 {t}"
return f"参数 {path} 类型错误: 期望 {t}"
def _check_value(value: Any, schema: Dict[str, Any], path: str,
errs: List[str], root: bool = False) -> None:
"""递归收集【全部】校验错误(对照 pi Errors() 全量上报)"""
if not isinstance(schema, dict):
return
if root and not isinstance(value, dict):
errs.append(f"参数必须是对象,实际是 {type(value).__name__}")
return
types = _norm_types(schema) or (["object"] if root else [])
if types and not any(_type_ok(value, t) for t in types):
errs.append(_type_error(path, types, value))
return # 类型不符 → 后续检查无意义
if "enum" in schema and value not in schema["enum"]:
errs.append(f"参数 {path} 取值必须是 {schema['enum']} 之一")
if isinstance(value, dict):
props = schema.get("properties") or {}
for req in schema.get("required") or []:
if req not in value:
errs.append(f"缺少必填参数: {req}" if root
else f"参数 {path} 缺少必填字段 {req}")
for k, v in value.items():
if k in props:
_check_value(v, props[k], _join_path(path, k), errs)
elif isinstance(value, list):
items = schema.get("items")
if isinstance(items, dict):
for i, item in enumerate(value):
_check_value(item, items, f"{path}[{i}]", errs)
def validate_json_schema(args: Any, schema: Dict[str, Any]) -> Optional[str]:
"""返回错误描述(多条以「; 」连接);合法返回 None。"""
errs: List[str] = []
_check_value(args, schema, "", errs, root=True)
return "; ".join(errs) if errs else None
def _validate_value(value: Any, schema: Dict[str, Any], path: str) -> Optional[str]:
"""(保留旧签名:返回该节点的首个错误)"""
errs: List[str] = []
_check_value(value, schema, path, errs)
return errs[0] if errs else None
def _brief_json(obj: Any, limit: int = 600) -> str:
"""参数回显(长内容截断,避免 write 的大 content 撑爆错误消息)"""
try:
s = json.dumps(obj, ensure_ascii=False)
except Exception:
s = repr(obj)
return s if len(s) <= limit else s[:limit] + f"…(共 {len(s)} 字符)"
# ======================================================================
# 准备阶段 —— 对照 agent-loop.ts prepareToolCalls
# ======================================================================
@dataclass
class PreparedToolCall:
"""一次工具调用的完整准备结果(执行前定型)"""
tool_call: ToolCall
tool: Optional[AgentTool]
args: Dict[str, Any]
error: str = "" # 准备阶段失败原因(未知工具/参数非法)→ 直接返回错误 toolResult
def prepare_tool_calls(assistant: AgentMessage,
tools: List[AgentTool]) -> List[PreparedToolCall]:
tool_map = {t.name: t for t in tools}
prepared: List[PreparedToolCall] = []
for tc in assistant.tool_calls:
tool = tool_map.get(tc.name)
if tool is None:
prepared.append(PreparedToolCall(
tool_call=tc, tool=None, args={},
error=f"未知工具: {tc.name}",
))
continue
if not isinstance(tc.arguments, dict):
prepared.append(PreparedToolCall(
tool_call=tc, tool=tool, args={},
error="工具参数解析失败(JSON 不完整)",
))
continue
# 🌟 工具自定义参数预处理(对照 pi tool.prepareArguments
raw_args = tc.arguments
if tool.prepare_arguments is not None:
try:
raw_args = tool.prepare_arguments(raw_args)
except Exception as e:
prepared.append(PreparedToolCall(
tool_call=tc, tool=tool, args=tc.arguments,
error=f"参数预处理失败: {e}",
))
continue
if not isinstance(raw_args, dict):
prepared.append(PreparedToolCall(
tool_call=tc, tool=tool, args=tc.arguments,
error="参数预处理返回的不是对象",
))
continue
# 🌟 归一化 + 轻量强制转换(null 可选字段删除 / "30" → 30
args = normalize_and_coerce(raw_args, tool.parameters)
err = validate_json_schema(args, tool.parameters)
if err:
prepared.append(PreparedToolCall(
tool_call=tc, tool=tool, args=args,
error=f"参数校验失败: {err};收到的参数: {_brief_json(raw_args)}",
))
continue
prepared.append(PreparedToolCall(tool_call=tc, tool=tool,
args=args))
return prepared
# ======================================================================
# 执行阶段 —— 对照 agent-loop.ts executeTool
# before 钩子 → 执行 → after 钩子 → 异常兜底
# ======================================================================
def execute_tool_call(prepared: PreparedToolCall,
assistant: AgentMessage,
config,
signal: AbortSignal,
on_update: Optional[Callable[[str], None]],
on_timer: Optional[Callable[[int, int], None]] = None,
) -> AgentToolResult:
"""对照 agent-loop.ts executePreparedToolCall + prepareToolCall 的钩子语义
🆕 on_timer(elapsed_s, timeout_s)bash 运行中每秒滴一次(气泡读秒)"""
tc = prepared.tool_call
# 准备阶段已失败 → 直接错误结果(pi 同款:不进入执行)
if prepared.error:
return AgentToolResult.text(prepared.error, is_error=True)
if signal.aborted:
return AgentToolResult.text("操作已中止 (Operation aborted)", is_error=True)
# before 钩子(对照 pi: 可修改 args / 拒绝 block / 请求 terminate
if config.before_tool_call:
try:
decision = config.before_tool_call(
{"assistant_message": assistant, "tool_call": tc,
"args": prepared.args, "context": config.tool_context},
signal)
except Exception as e:
return AgentToolResult.text(f"before 钩子异常: {e}", is_error=True)
if signal.aborted:
return AgentToolResult.text("操作已中止 (Operation aborted)",
is_error=True)
if decision:
if decision.get("block"):
# 🐛 修复:旧代码向 AgentToolResult.text() 传了不存在的 terminate 参数
# (钩子一旦 block 就抛 TypeError)—— 改为直接构造,保留 terminate 语义
return AgentToolResult(
content=[{"type": "text",
"text": decision.get("reason") or "工具执行被拦截"}],
is_error=True,
terminate=bool(decision.get("terminate")))
if decision.get("args") is not None:
# 🌟 对照 pi applyBeforeToolDecision:钩子改参后【重新校验】
new_args = decision["args"]
if not isinstance(new_args, dict):
return AgentToolResult.text(
"before 钩子返回的参数不是对象", is_error=True)
new_args = normalize_and_coerce(new_args, prepared.tool.parameters)
_verr = validate_json_schema(new_args, prepared.tool.parameters)
if _verr:
return AgentToolResult.text(
f"before 钩子修改后的参数校验失败: {_verr}"
f"收到的参数: {_brief_json(decision['args'])}",
is_error=True)
prepared.args = new_args
t0 = time.time()
try:
# 🆕 秒级滴答回调注入 ctx(不污染调用方的 tool_context 字典)
_ctx = dict(config.tool_context or {})
if on_timer is not None:
_ctx["on_timer"] = on_timer
result = prepared.tool.execute(
tc.id, prepared.args, signal, on_update, _ctx,
)
if not isinstance(result, AgentToolResult):
# 宽容处理:工具返回 str 也接受
result = AgentToolResult.text(str(result))
except Exception as e:
result = AgentToolResult.text(f"工具执行异常: {e}", is_error=True)
# after 钩子
if config.after_tool_call:
try:
config.after_tool_call(tc, result, result.is_error)
except Exception:
pass
# 结果定型(对照 pi finalizeToolResult:确保 content 结构合法)
if not result.content:
result = AgentToolResult.text("(无输出)")
result.details = {"duration_ms": int((time.time() - t0) * 1000),
**(result.details or {} if isinstance(result.details, dict) else {})}
return result
# ======================================================================
# 截断保护 —— 对照 agent-loop.ts failToolCallsFromTruncatedMessage
# 助手消息被 length/aborted 截断时,其工具调用参数可能残缺:
# 一律替换为错误 toolResult,绝不执行残缺调用。
# ======================================================================
def fail_tool_calls_from_truncated_message(assistant: AgentMessage,
reason: str = "aborted") -> List[AgentMessage]:
"""为每个工具调用生成错误 toolResult 消息(对照 pi 返回值的组装)"""
msgs: List[AgentMessage] = []
for tc in assistant.tool_calls:
# 对照 pi: "Tool call {name} was not executed: the response hit the
# output token limit before the arguments were complete..."
text = (f"工具调用 {tc.name} 未执行({reason}):"
f"响应在参数完整前达到输出长度上限,部分参数已被丢弃。"
f"请用完整参数重试该操作。")
msgs.append(AgentMessage(
role="toolResult",
tool_call_id=tc.id,
tool_name=tc.name,
content=text,
is_error=True,
))
return msgs
# ======================================================================
# 内置工具 —— 对照 pi coding-agent 的 read / bash / write / edit
# ======================================================================
_MAX_READ_LINES = 2000
_MAX_OUTPUT_BYTES = 50 * 1024
def _resolve_path(path: str, ctx: Dict[str, Any]) -> str:
"""相对路径基于 tool_context 的 cwd(默认项目根)"""
if os.path.isabs(path):
return os.path.abspath(path)
cwd = ctx.get("cwd", os.getcwd())
return os.path.abspath(os.path.join(cwd, path))
def tool_read(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
on_update, ctx: Dict[str, Any]) -> AgentToolResult:
"""read: 读取文本文件(支持 offset/limit 行窗口),带行号输出"""
if not args.get("path"):
return AgentToolResult.text("path 不能为空", is_error=True)
path = _resolve_path(args["path"], ctx)
if not os.path.isfile(path):
return AgentToolResult.text(f"文件不存在: {path}", is_error=True)
try:
offset = max(1, int(args.get("offset", 1)))
except (TypeError, ValueError):
return AgentToolResult.text("offset 必须是整数", is_error=True)
try:
# 🌟 limit 必须有下限:负数会被 python 负索引语义吃掉
# (旧版 limit=-5 → lines[0:-5] → 除末尾 5 行外全部返回,与直觉完全相反)
limit = max(1, min(int(args.get("limit", 2000)), _MAX_READ_LINES))
except (TypeError, ValueError):
return AgentToolResult.text("limit 必须是整数", is_error=True)
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except Exception as e:
return AgentToolResult.text(f"读取失败: {e}", is_error=True)
total = len(lines)
if total == 0:
return AgentToolResult.text("[文件为空(0 行)]")
if offset > total:
return AgentToolResult.text(
f"[起始行 offset={offset} 超出文件范围,该文件共 {total} 行]")
chunk = lines[offset - 1: offset - 1 + limit]
numbered = "".join(
f"{i + offset:6d}\t{line}" for i, line in enumerate(chunk)
)
shown_hi = offset - 1 + len(chunk)
footer = f"\n[已显示 {offset}{shown_hi} 行,共 {total} 行]"
if shown_hi < total:
footer += f"(还有 {total - shown_hi} 行未显示,用 offset={shown_hi + 1} 继续)"
out = numbered + footer
if len(out.encode("utf-8")) > _MAX_OUTPUT_BYTES:
out = out.encode("utf-8")[:_MAX_OUTPUT_BYTES].decode("utf-8", "ignore")
out += "\n[输出超过 50KB 已截断]"
return AgentToolResult.text(out)
def tool_bash(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
on_update, ctx: Dict[str, Any]) -> AgentToolResult:
"""bash: 执行 shell 命令(默认 120s 超时)。对照 pi: bash 是 sequential 工具
🆕 秒级滴答:Popen + communicate(timeout=1) 循环,每秒:
① 日志 [timer] bash 读秒 N/Ts
② on_timer(N, T) 推前端气泡读秒
③ 到期杀进程树(Windows taskkill /T,连孤儿子进程一起杀)→ 真超时
"""
if signal.aborted:
return AgentToolResult.text("操作已中止 (Operation aborted)", is_error=True)
command = args.get("command", "")
if not command.strip():
return AgentToolResult.text("command 不能为空", is_error=True)
timeout = min(float(args.get("timeout", 120)), 600)
cwd = ctx.get("cwd") or os.getcwd()
# 对照 pi bash 工具:命令始终经 shell 解释(支持管道/别名/内置命令)
use_shell = ctx.get("shell", True)
on_timer = ctx.get("on_timer")
t0 = time.time()
# 🆕 计时观察①:计时器启动时刻 + 模型实际传的 timeout 值
_dbg_log(f"[timer] bash 开始 timeout={timeout:.0f}s "
f"(显式={args.get('timeout')}) cmd={command[:80]!r}")
def _tick(elapsed_i: int):
"""每秒一次:日志 + 推前端(静默吞异常,绝不影响执行)"""
_dbg_log(f"[timer] bash 读秒 {elapsed_i}/{int(timeout)}s")
try:
if on_timer:
on_timer(elapsed_i, int(timeout))
except Exception:
pass
def _kill_tree(proc):
"""杀整个进程树:Windows 上默认 kill 只杀 cmd 壳,孤儿子进程继续
持管道 → 假超时(设定10s 实际20s)。taskkill /T 整树杀。"""
try:
if os.name == "nt":
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True, timeout=10)
else:
proc.kill()
except Exception:
try:
proc.kill()
except Exception:
pass
# 🐛 修复:text=True 不带 encoding 时按系统码页(中文 Windows=GBK)解码,
# 子进程输出 UTF-8python/git/中文 echo)→ _readerthread UnicodeDecodeError。
# 强制 UTF-8 + 容错替换;PYTHONIOENCODING 让 python 子进程也按 UTF-8 输出。
_env = dict(os.environ, PYTHONIOENCODING="utf-8")
try:
proc = subprocess.Popen(
command, shell=use_shell,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace",
cwd=cwd, env=_env,
)
except Exception as e:
return AgentToolResult.text(f"执行失败: {e}", is_error=True)
timed_out = False
out_parts: List[str] = []
err_parts: List[str] = []
err_header_fed = False
# 🆕 实时输出:stdout/stderr 各起一个 reader 线程 → 队列,
# 主循环(仍在 worker 线程内)抽干队列并回调 on_update。
# 绝不从 reader 线程直接回调 —— UI 侧 _on_tool_updated 会改 timeline,跨线程不安全。
_q: "queue.Queue" = queue.Queue()
def _reader(stream, tag):
try:
for line in iter(stream.readline, ""):
_q.put((tag, line))
except Exception:
pass
finally:
try:
stream.close()
except Exception:
pass
_t_out = threading.Thread(target=_reader, args=(proc.stdout, "out"), daemon=True)
_t_err = threading.Thread(target=_reader, args=(proc.stderr, "err"), daemon=True)
_t_out.start()
_t_err.start()
def _feed(tag, chunk):
"""抽到一块输出:累积 + 推实时流(只在 worker 线程内调用)"""
nonlocal err_header_fed
piece = chunk
if tag == "err":
err_parts.append(chunk)
if not err_header_fed:
err_header_fed = True
piece = "[stderr]\n" + chunk
else:
out_parts.append(chunk)
try:
if on_update and piece:
on_update(piece)
except Exception:
pass
def _drain():
"""抽干队列里已到达的输出(单轮上限防极端刷屏卡死)"""
for _ in range(500):
try:
tag, chunk = _q.get_nowait()
except queue.Empty:
return
_feed(tag, chunk)
last_tick = 0 # 首次滴答仍在 elapsed=1(与旧 communicate 实现一致,不在 0 秒多滴一次)
try:
while True:
# ① 最多 0.2s 醒一次 → 把新输出实时推给前端
try:
tag, chunk = _q.get(timeout=0.2)
_feed(tag, chunk)
except queue.Empty:
pass
_drain()
# ② 进程已退出:等 reader 读完管道残余 → 收尾
if proc.poll() is not None:
_t_out.join(timeout=1.0)
_t_err.join(timeout=1.0)
_drain()
break
# ③ 每秒读秒 + 中止/超时判定(原语义不变)
elapsed_i = int(time.time() - t0)
if elapsed_i > last_tick:
last_tick = elapsed_i
if signal.aborted:
_kill_tree(proc)
_t_out.join(timeout=1.0)
_t_err.join(timeout=1.0)
_drain()
return AgentToolResult.text(
"操作已中止 (Operation aborted)", is_error=True)
_tick(elapsed_i)
if time.time() - t0 >= timeout:
# 到期 → 真杀(进程树)
timed_out = True
_kill_tree(proc)
try:
proc.wait(timeout=15)
except Exception:
pass
_t_out.join(timeout=5.0)
_t_err.join(timeout=5.0)
_drain()
break
except Exception as e:
try:
_kill_tree(proc)
except Exception:
pass
return AgentToolResult.text(f"执行失败: {e}", is_error=True)
out = "".join(out_parts)
err = "".join(err_parts)
code = proc.returncode if proc.returncode is not None else -1
dur = time.time() - t0
if timed_out:
# 🆕 计时观察②:计时器到期
_dbg_log(f"[timer] bash 超时触发 设定={timeout:.0f}s "
f"实际={dur:.1f}s cmd={command[:80]!r}")
return AgentToolResult.text(f"命令超时(>{timeout:.0f}s)已终止", is_error=True)
# 🆕 计时观察③:正常结束 + 实际耗时
_dbg_log(f"[timer] bash 正常结束 dur={dur:.1f}s "
f"exit={code} 设定timeout={timeout:.0f}s")
result = f"$ {command}\n"
if out:
result += out if out.endswith("\n") else out + "\n"
if err:
result += f"[stderr]\n{err}"
result += f"\n[exit {code}] ({dur:.1f}s)"
if len(result.encode("utf-8")) > _MAX_OUTPUT_BYTES:
result = result.encode("utf-8")[:_MAX_OUTPUT_BYTES].decode("utf-8", "ignore")
result += "\n[输出超过 50KB 已截断]"
return AgentToolResult.text(result, is_error=code != 0,
details={"exit_code": code})
def _atomic_write(path: str, content: str) -> None:
"""原子写:同目录临时文件 + os.replace(避免半截文件)
保留原有 newline 语义(默认 None → 平台换行翻译),仅增加原子性。
"""
d = os.path.dirname(os.path.abspath(path)) or "."
fd, tmp = tempfile.mkstemp(dir=d, prefix=".hocode_w_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp, path) # Windows/POSIX 均为原子替换
except BaseException:
try:
os.remove(tmp)
except Exception:
pass
raise
def tool_write(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
on_update, ctx: Dict[str, Any]) -> AgentToolResult:
"""write: 创建/覆盖文件(自动建父目录)"""
if not args.get("path"):
return AgentToolResult.text("path 不能为空", is_error=True)
path = _resolve_path(args["path"], ctx)
content = args.get("content", "")
if content is None:
content = ""
if not isinstance(content, str):
content = str(content)
try:
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
_atomic_write(path, content)
except Exception as e:
return AgentToolResult.text(f"写入失败: {e}", is_error=True)
return AgentToolResult.text(f"已写入 {len(content)} 字符 → {path}")
def _prepare_edit_args(args: Any) -> Any:
"""归一化 edit 参数(对照 pi edit.ts:56-70 prepareEditArguments
- edits 为 JSON 字符串 → 解析为数组
- edits 内条目为 JSON 字符串 → 解析为对象
- 兼容 legacy 单条形式 {path, oldText, newText} → 包装为 edits:[{...}]
"""
if not isinstance(args, dict):
return args
out = dict(args)
edits = out.get("edits")
if isinstance(edits, str):
try:
parsed = json.loads(edits)
if isinstance(parsed, list):
out["edits"] = parsed
elif isinstance(parsed, dict):
out["edits"] = [parsed]
except Exception:
pass # 解析不了就交给校验层报错
if out.get("edits") is None and ("oldText" in out or "newText" in out):
ed: Dict[str, Any] = {}
if "oldText" in out:
ed["oldText"] = out["oldText"]
if "newText" in out:
ed["newText"] = out["newText"]
out = {"path": out.get("path"), "edits": [ed]}
if isinstance(out.get("edits"), list):
norm = []
for it in out["edits"]:
if isinstance(it, str):
try:
it = json.loads(it)
except Exception:
pass
norm.append(it)
out["edits"] = norm
return out
def _plan_edits(content: str, edits: List[Any]):
"""对【原始内容】定位每条 oldText(要求恰好 1 次)+ 区间重叠检测
对照 pi edit-diff.ts:348「edits[i] and edits[j] overlap … Merge them into one edit」。
返回 (错误信息, [(start, end, index, new_text), ...] 已按 start 排序)
"""
spans: List[tuple] = []
for i, ed in enumerate(edits):
if not isinstance(ed, dict):
return f"{i + 1} 条 edit 不是对象", None
old = ed.get("oldText", "")
if not isinstance(old, str):
return f"{i + 1} 条 edit 的 oldText 必须是字符串", None
if old == "":
return f"{i + 1} 条 edit 的 oldText 不能为空", None
c = content.count(old)
if c == 0:
return f"{i + 1} 条 edit 未找到匹配文本(oldText 不存在或已变化)", None
if c > 1:
return f"{i + 1} 条 edit 匹配到 {c} 处(要求唯一),请提供更长的上下文", None
start = content.index(old)
spans.append((start, start + len(old), i, ed.get("newText", "")))
ordered = sorted(spans)
for a, b in zip(ordered, ordered[1:]):
if b[0] < a[1]:
return (f"edits[{a[2]}] 与 edits[{b[2]}] 区域重叠,"
f"请合并为一条 edit 或改为互不相交的修改"), None
return None, ordered
def tool_edit(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
on_update, ctx: Dict[str, Any]) -> AgentToolResult:
"""edit: 精确文本替换(edits: [{oldText, newText}]
🌟 语义对齐 pi:全部 edit 都对【原始文件内容】定位,要求各自唯一且区间互不重叠,
然后按偏移一次性重建(不是逐条 replace 的增量语义)。
"""
if not args.get("path"):
return AgentToolResult.text("path 不能为空", is_error=True)
path = _resolve_path(args["path"], ctx)
if not os.path.isfile(path):
return AgentToolResult.text(f"文件不存在: {path}", is_error=True)
edits = args.get("edits", [])
if not isinstance(edits, list) or not edits:
return AgentToolResult.text("edits 不能为空", is_error=True)
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
return AgentToolResult.text(f"读取失败: {e}", is_error=True)
err, ordered = _plan_edits(content, edits)
if err:
return AgentToolResult.text(err, is_error=True)
# 按偏移从后往前替换(前面的偏移不受影响)
out = content
for start, end, _i, new_text in reversed(ordered):
out = out[:start] + (new_text if isinstance(new_text, str) else str(new_text)) + out[end:]
try:
_atomic_write(path, out)
except Exception as e:
return AgentToolResult.text(f"编辑失败: {e}", is_error=True)
return AgentToolResult.text(f"已应用 {len(ordered)} 处编辑 → {path}")
# ======================================================================
# 默认工具集 —— 对照 pi 默认启用 read/bash/edit/write
# ======================================================================
_TEXT_TOOL_RE = re.compile(r"<(bash|read)>(.*?)</\1>", re.DOTALL)
def parse_text_tool_calls(content: str):
"""
兜底解析(haocode 扩展,pi 无此层):
对不支持 tools API 的供应商/模型——它们会把工具调用用纯文字""出来
(例如 <bash>ls</bash>、<read>路径</read>),本函数识别单参数工具
bash / read 并转成真 ToolCall 供循环执行。
write / edit 参数多、文本歧义大,不做兜底(保持安全)。
返回 (原文, [ToolCall]);未命中时 calls 为空列表。
"""
if not content or "<" not in content:
return content, []
calls: List[ToolCall] = []
def _sub(m):
name, payload = m.group(1), m.group(2).strip()
if not payload:
return m.group(0)
if name == "bash":
calls.append(ToolCall(id=new_id("txtcall"), name="bash",
arguments={"command": payload}))
elif name == "read":
calls.append(ToolCall(id=new_id("txtcall"), name="read",
arguments={"path": payload}))
return m.group(0) # 保留原文(UI 已渲染,不回改;执行由 tool_calls 驱动)
cleaned = _TEXT_TOOL_RE.sub(_sub, content)
return cleaned, calls
def default_tools() -> List[AgentTool]:
return [
AgentTool(
name="read", label="读取文件",
description="读取文本文件内容(带行号)。支持 offset/limit 按行窗口读取大文件。",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"},
"offset": {"type": "integer", "description": "起始行号(从 1 开始)"},
"limit": {"type": "integer", "description": "最多读取行数(默认 2000"},
},
"required": ["path"],
},
execute=tool_read,
),
AgentTool(
name="bash", label="执行命令",
description="执行 shell 命令并返回 stdout/stderr/退出码。默认 120 秒超时。",
parameters={
"type": "object",
"properties": {
"command": {"type": "string", "description": "要执行的命令"},
"timeout": {"type": "number", "description": "超时秒数(默认 120,最大 600"},
},
"required": ["command"],
},
execute=tool_bash,
execution_mode="sequential", # 对照 pi: bash 整批串行
),
AgentTool(
name="write", label="写入文件",
description="创建或覆盖写入文件(自动创建父目录)。",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"},
"content": {"type": "string", "description": "文件内容"},
},
"required": ["path", "content"],
},
execute=tool_write,
),
AgentTool(
name="edit", label="编辑文件",
description=("对文件做精确文本替换。edits 中每条 oldText 必须在原文件中唯一,"
"且各条区间不得重叠(重叠请合并为一条)。"),
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"},
"edits": {
"type": "array",
"items": {
"type": "object",
"properties": {
"oldText": {"type": "string"},
"newText": {"type": "string"},
},
"required": ["oldText"],
},
"description": "替换操作列表",
},
},
"required": ["path", "edits"],
},
execute=tool_edit,
prepare_arguments=_prepare_edit_args,
),
]
+331
View File
@@ -0,0 +1,331 @@
"""
core/agent/types.py
====================
🌟 pi agent-core 的 Python 1:1 移植 —— 数据模型层
对照 pi-main 源码:
packages/agent/src/types.ts (443 行)
- AgentMessage 联合类型 (user / assistant / toolResult)
- AgentEvent 11 种事件
- AgentTool / AgentToolCall / AgentToolResult
- AgentState / AgentConfig / AgentError
设计原则(与 pi 完全一致):
1. 消息是不可变事实(append-only),循环只追加、不修改
2. 事件是唯一对外输出(TUI/Qt 都只是事件订阅者)
3. 工具 = (name, description, JSON-Schema 参数, execute 函数)
4. 中止用 AbortSignal 标志位,不用异常控制流
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
def _now_ms() -> int:
return int(time.time() * 1000)
def new_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:16]}"
# ======================================================================
# 中止信号 —— 对照 pi: AbortController/AbortSignalWeb 标准)
# pi 里 streamSimple 每消费一个 chunk 检查 signal.aborted
# ======================================================================
class AbortSignal:
def __init__(self):
self.aborted: bool = False
self.reason: str = ""
def abort(self, reason: str = "aborted"):
self.aborted = True
self.reason = reason
# ======================================================================
# 消息模型 —— 对照 types.ts 的 AgentMessage 联合类型
# pi: type AgentMessage = AgentUserMessage | AgentAssistantMessage | AgentToolResultMessage
# ======================================================================
@dataclass
class ToolCall:
"""pi: AssistantMessage 内容块里的 {type:"toolCall", id, name, arguments}"""
id: str
name: str
arguments: Dict[str, Any] = field(default_factory=dict)
# 流式阶段累积的原始 JSON 字符串(参数被截断时可能是残缺 JSON)
raw_arguments: str = ""
@dataclass
class AgentMessage:
"""
统一消息容器。role 决定哪些字段有效:
user -> content: str 或 OpenAI 多模态 list
assistant -> content(str 正文) + reasoning(思考) + tool_calls + stop_reason + usage
toolResult -> tool_call_id + tool_name + content + is_error
与 pi 的三种 Message 类型字段一一对应。
"""
role: str # "user" | "assistant" | "toolResult"
content: Any = "" # str | List[dict]
reasoning: str = "" # pi: thinking blockvLLM: reasoning_content
tool_calls: List[ToolCall] = field(default_factory=list)
tool_call_id: str = "" # toolResult 专用
tool_name: str = "" # toolResult 专用
is_error: bool = False # toolResult 专用
stop_reason: str = "" # assistant 专用: stop|length|aborted|error
error_message: str = "" # assistant 出错时的说明
usage: Dict[str, Any] = field(default_factory=dict) # {input, output, cacheRead...}
kind: str = "" # "" | "compaction_summary"(对照 pi compaction 条目)
db_msg_id: str = "" # 🆕 压缩持久化:对应 DB 行的 idbuild_api_context 注入,切点计算用)
timestamp: int = field(default_factory=_now_ms)
id: str = field(default_factory=lambda: new_id("msg"))
def to_dict(self) -> Dict[str, Any]:
return {
"role": self.role, "content": self.content, "reasoning": self.reasoning,
"tool_calls": [
{"id": tc.id, "name": tc.name, "arguments": tc.arguments,
"raw_arguments": tc.raw_arguments}
for tc in self.tool_calls
],
"tool_call_id": self.tool_call_id, "tool_name": self.tool_name,
"is_error": self.is_error, "stop_reason": self.stop_reason,
"error_message": self.error_message, "usage": self.usage,
"kind": self.kind,
"db_msg_id": self.db_msg_id,
"timestamp": self.timestamp, "id": self.id,
}
@staticmethod
def from_dict(d: Dict[str, Any]) -> "AgentMessage":
return AgentMessage(
role=d.get("role", "user"), content=d.get("content", ""),
reasoning=d.get("reasoning", ""),
tool_calls=[ToolCall(id=t.get("id", ""), name=t.get("name", ""),
arguments=t.get("arguments", {}),
raw_arguments=t.get("raw_arguments", ""))
for t in d.get("tool_calls", [])],
tool_call_id=d.get("tool_call_id", ""), tool_name=d.get("tool_name", ""),
is_error=d.get("is_error", False), stop_reason=d.get("stop_reason", ""),
error_message=d.get("error_message", ""), usage=d.get("usage", {}),
kind=d.get("kind", ""),
db_msg_id=d.get("db_msg_id", ""),
timestamp=d.get("timestamp", 0), id=d.get("id") or new_id("msg"),
)
# ======================================================================
# 助手消息的流式增量事件 —— 对照 pi: AssistantMessageEvent
# {type:"text_delta", textDelta} | {type:"thinking_delta", ...}
# | {type:"toolcall_delta", ...} | {type:"done"} | {type:"error"}
# ======================================================================
@dataclass
class AssistantMessageEvent:
type: str # "text_delta" | "thinking_delta" | "toolcall_delta" | "done" | "error"
text: str = "" # text_delta / thinking_delta 的增量
tool_call_index: int = -1 # toolcall_delta 属于第几个 toolCall
tool_call_delta: str = "" # toolcall_delta 的原始增量片段
tool_call_field: str = "arguments" # 片段属于 id | name | arguments 哪一段
error_message: str = "" # error 时的说明
@staticmethod
def text_delta(text: str) -> "AssistantMessageEvent":
return AssistantMessageEvent(type="text_delta", text=text)
@staticmethod
def thinking_delta(text: str) -> "AssistantMessageEvent":
return AssistantMessageEvent(type="thinking_delta", text=text)
@staticmethod
def toolcall_delta(index: int, delta: str,
field: str = "arguments") -> "AssistantMessageEvent":
return AssistantMessageEvent(type="toolcall_delta", tool_call_index=index,
tool_call_delta=delta, tool_call_field=field)
@staticmethod
def done() -> "AssistantMessageEvent":
return AssistantMessageEvent(type="done")
@staticmethod
def error(message: str) -> "AssistantMessageEvent":
return AssistantMessageEvent(type="error", error_message=message)
# ======================================================================
# Agent 事件(11 种)—— 对照 types.ts 的 AgentEvent 联合类型
# ======================================================================
EVENT_TYPES = (
"agent_start", "agent_end",
"turn_start", "turn_end",
"message_start", "message_update", "message_end",
"tool_execution_start", "tool_execution_update", "tool_execution_end",
)
@dataclass
class AgentEvent:
"""
事件容器。字段随 type 变化:
agent_end -> stop_reason, error
message_start/end -> message (AgentMessage)
message_update -> message(当前累积快照), assistant_message_event
tool_execution_start -> tool_call, tool_name
tool_execution_update -> tool_call, arg(增量)
tool_execution_timer -> tool_call, arg=(已耗秒, 超时秒) 🆕 bash 读秒
tool_execution_end -> tool_call, result, is_error
"""
type: str
message: Optional[AgentMessage] = None
assistant_message_event: Optional[AssistantMessageEvent] = None
tool_call: Optional[ToolCall] = None
arg: str = ""
result: Optional["AgentToolResult"] = None
is_error: bool = False
stop_reason: str = ""
error: Optional["AgentError"] = None
messages: List[AgentMessage] = field(default_factory=list) # agent_end 携带的本轮新消息
# ======================================================================
# 工具 —— 对照 types.ts: AgentTool / AgentToolCall / AgentToolResult
# ======================================================================
@dataclass
class AgentToolResult:
"""pi: {content: OutputContent[], details?, isError, terminate?}"""
content: List[Dict[str, Any]] = field(default_factory=list) # [{type:"text", text}]
details: Any = None
is_error: bool = False
terminate: bool = False # 对照 pi: 工具可请求终止整个批次/循环
@staticmethod
def text(text: str, is_error: bool = False, details: Any = None) -> "AgentToolResult":
return AgentToolResult(content=[{"type": "text", "text": text}],
details=details, is_error=is_error)
def as_text(self) -> str:
return "".join(c.get("text", "") for c in self.content if c.get("type") == "text")
@dataclass
class AgentTool:
"""
pi: interface AgentTool<T> { name; description; parameters(JSON Schema);
execute(toolCallId, args, signal, onUpdate, context) => Promise<ToolResult> }
Python 版 execute 签名完全一致(同步执行,循环里用线程池并发)。
"""
name: str
description: str
parameters: Dict[str, Any] # JSON Schema
execute: Callable # (tool_call_id, args, signal, on_update, context) -> AgentToolResult
label: str = "" # UI 展示用短标签
# 对照 pi types.ts: executionMode —— "sequential" 工具会把整个批次拉回串行执行
execution_mode: str = "parallel"
# 🌟 对照 pi AgentHarnessTool.prepareArguments:校验前的确定性参数预处理
# (如 edit 兼容 legacy 单条形式 / edits 为 JSON 字符串),签名 (args) -> args
prepare_arguments: Optional[Callable] = None
# ======================================================================
# 运行时状态 / 配置 —— 对照 types.ts: AgentState / AgentConfig
# ======================================================================
@dataclass
class AgentState:
messages: List[AgentMessage] = field(default_factory=list)
is_streaming: bool = False
streaming_message: Optional[AgentMessage] = None # 正在流式生成的助手消息
streaming_delta: Dict[str, str] = field(default_factory=dict) # 各通道当前增量缓冲
error: Optional["AgentError"] = None
@dataclass
class RetryConfig:
"""对照 pi settings-manager.ts retry 默认值(行 32-33):
maxRetries=3, baseDelayMs=2000,指数退避 2s → 4s → 8sbase × 2^(n-1)
max_delay_ms=0 表示不设上限(pi _prepareRetry 无封顶)"""
max_attempts: int = 3
base_delay_ms: int = 2000
factor: float = 2.0
max_delay_ms: int = 0 # 0 = 不封顶
@dataclass
class ModelConfig:
"""
对照 pi models.json 里的模型定义。
context_window = pi 的 contextWindowmax_tokens = pi 的 maxTokens。
"""
provider: str = ""
name: str = ""
context_window: int = 128000
max_tokens: int = 8192
# 请求参数
temperature: float = 0.7
timeout_seconds: float = 180.0
api_key: str = ""
base_url: str = "https://api.openai.com/v1"
# 🆕 thinking 模式(DeepSeek 等):assistant 消息必须把 reasoning_content
# 原样回传,否则 400。默认开启(只影响有 reasoning 的轮中消息;
# DB 回放行 reasoning="" 不带字段 → 全新请求体不变)。
pass_reasoning: bool = True
@dataclass
class AgentConfig:
"""
对照 types.ts: AgentConfig
model / tools / systemPrompt / maxTurns / steeringMode / followUpMode
+ 6 个循环钩子(transformContext / beforeToolCall / afterToolCall /
shouldStopAfterTurn / prepareNextTurn
"""
model: ModelConfig
system_prompt: str = ""
tools: List[AgentTool] = field(default_factory=list)
max_turns: int = 50 # pi 默认 50
steering_mode: str = "one-at-a-time" # pi 默认 "one-at-a-time""all" 一次性全注入)
follow_up_mode: str = "one-at-a-time"
# 对照 pi types.ts ToolExecutionMode: 批次级执行模式(有 sequential 工具自动转串行)
tool_execution: str = "parallel" # "sequential" | "parallel"
retry: RetryConfig = field(default_factory=RetryConfig)
# 压缩(🌟 1:1 对照 pi DEFAULT_COMPACTION_SETTINGS
compaction_reserve: int = 16384 # reserveTokens: 摘要提示词与输出预留
compaction_keep_recent: int = 20000 # keepRecentTokens: 压缩后保留的近期上下文预算
# 钩子(None = 无钩子,对照 pi 的可选字段)
transform_context: Optional[Callable] = None # (messages) -> messages
before_tool_call: Optional[Callable] = None # (tool_call, args) -> 可修改/拒绝
after_tool_call: Optional[Callable] = None # (tool_call, result, is_error) -> None
should_stop_after_turn: Optional[Callable] = None # (messages) -> bool
prepare_next_turn: Optional[Callable] = None # (context) -> context
# 🆕 haocode 增强(偏离 pi 1:1):轮中请求前的主动压缩检查
# (messages) -> messages|None:返回新列表 = 发生了压缩(循环需同步上下文);
# None = 不需要/不可压缩(原样发请求,响应式安全网仍在)
compact_fn: Optional[Callable] = None # (messages) -> messages|None
# 运行上下文(透传给 tool.execute,如工作目录)
tool_context: Dict[str, Any] = field(default_factory=dict)
@dataclass
class AgentError(Exception):
"""对照 pi 的 AgentError —— 携带分类信息供恢复逻辑判定"""
message: str = ""
# 分类: "rate_limit" | "overload" | "server_error" | "timeout" |
# "connection" | "auth" | "unknown"
kind: str = "unknown"
status_code: Optional[int] = None
recoverable: bool = False # 恢复逻辑判定后的标记
def __str__(self):
return self.message or "AgentError"
# ======================================================================
# 运行结果 —— 对照 agent-session.ts 里对 agent_end 事件的读取
# ======================================================================
@dataclass
class RunResult:
stop_reason: str = "stop" # stop | length | aborted | error
error: Optional[AgentError] = None
message_count: int = 0
+715
View File
@@ -0,0 +1,715 @@
import sqlite3
import os
import sys
import re
import json
import uuid
import time
import shutil
from typing import List, Dict, Optional
# 🝙 P0 fix: DB path based on the file's location (no longer depends on the process's working directory)
# 🌟 打包(PyInstaller onedir)时:优先与源码树共用 data/chat_history.db(历史不丢失);
# 若 exe 被复制到源码树之外 → 回退为 exe 旁的 data/chat_history.db
if getattr(sys, "frozen", False):
_exe_dir = os.path.dirname(os.path.abspath(sys.executable))
_shared = os.path.abspath(os.path.join(_exe_dir, "..", "..", "data", "chat_history.db"))
if os.path.isdir(os.path.dirname(_shared)):
_DEFAULT_DB = _shared
else:
_DEFAULT_DB = os.path.join(_exe_dir, "data", "chat_history.db")
else:
_DEFAULT_DB = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "data", "chat_history.db")
class _AutoCloseConn:
"""🝙 P0 fix: wraps sqlite3.Connection.
sqlite3.Connection's with only handles commit/rollback and does NOT close.
All 19 call sites use `with self.get_connection() as conn:`, so we auto-close on with exit.
"""
def __init__(self, conn):
self._conn = conn
def __getattr__(self, name):
return getattr(self._conn, name)
def __enter__(self):
self._conn.__enter__()
return self
def __exit__(self, exc_type, exc, tb):
try:
return self._conn.__exit__(exc_type, exc, tb)
finally:
try:
self._conn.close()
except Exception:
pass
class DBManager:
def __init__(self, db_path: str = None):
# 🝙 Default path is anchored to the core/ directory, not affected by launch CWD
self.db_path = db_path or os.path.abspath(_DEFAULT_DB)
# 🆕 附件/媒体文件根目录(copy_session 深拷贝磁盘文件用)。默认=项目根;测试可覆盖。
self.files_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.is_first_run = not os.path.exists(self.db_path)
d = os.path.dirname(self.db_path)
if d:
os.makedirs(d, exist_ok=True)
self._init_db()
def get_connection(self):
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return _AutoCloseConn(conn)
def _init_db(self):
"""初始化表结构并注入默认数据"""
with self.get_connection() as conn:
cursor = conn.cursor()
# 1. 创建 sessions 表 (新增 current_leaf_msg_id)
cursor.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT,
created_at INTEGER,
updated_at INTEGER,
has_messages BOOLEAN DEFAULT 0,
sort_order INTEGER DEFAULT 0,
is_starred BOOLEAN DEFAULT 0,
current_leaf_msg_id TEXT
)
""")
# 2. 创建 messages 表 (新增 parent_id)
cursor.execute("""
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
reasoning TEXT,
is_ignored BOOLEAN,
created_at INTEGER,
attachment_metadata TEXT,
parent_id TEXT,
timeline TEXT,
usage TEXT,
stop_reason TEXT,
error_message TEXT,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
""")
# ==========================================
# 🌟 核心性能优化:为高频查询的字段建立索引
# ==========================================
cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_id ON messages(session_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_parent_id ON messages(parent_id)")
# 3. 🌟 自动化热升级:检测并兼容旧数据库
self._upgrade_schema(cursor)
# 4. 检查是否需要插入初始默认对话
cursor.execute("SELECT COUNT(*) FROM sessions")
if cursor.fetchone()[0] == 0:
self._seed_default_chat(cursor)
conn.commit()
def _upgrade_schema(self, cursor):
"""检测缺少的新字段并自动补齐,如果是刚升级,则自动将旧线性数据串联成链表"""
upgraded = False
# 兼容 sessions 字段
for col in ["has_messages", "sort_order", "is_starred"]:
try:
cursor.execute(f"SELECT {col} FROM sessions LIMIT 1")
except sqlite3.OperationalError:
if col == "has_messages": cursor.execute("ALTER TABLE sessions ADD COLUMN has_messages BOOLEAN DEFAULT 0")
if col == "sort_order": cursor.execute("ALTER TABLE sessions ADD COLUMN sort_order INTEGER DEFAULT 0")
if col == "is_starred": cursor.execute("ALTER TABLE sessions ADD COLUMN is_starred BOOLEAN DEFAULT 0")
# 🌟 核心:兼容链表树架构
try:
# 兼容 sessions.mode 列(chat/worker 模式锁定,NULL=未发送过)
try:
cursor.execute("SELECT mode FROM sessions LIMIT 1")
except Exception:
cursor.execute("ALTER TABLE sessions ADD COLUMN mode TEXT")
cursor.execute("SELECT current_leaf_msg_id FROM sessions LIMIT 1")
except sqlite3.OperationalError:
print("[DB]: 正在升级 sessions 表 (添加 current_leaf_msg_id)...")
cursor.execute("ALTER TABLE sessions ADD COLUMN current_leaf_msg_id TEXT")
upgraded = True
try:
cursor.execute("SELECT attachment_metadata, parent_id FROM messages LIMIT 1")
except sqlite3.OperationalError:
print("[DB]: 正在升级 messages 表 (添加 attachment_metadata, parent_id)...")
try: cursor.execute("ALTER TABLE messages ADD COLUMN attachment_metadata TEXT")
except: pass
try: cursor.execute("ALTER TABLE messages ADD COLUMN parent_id TEXT")
except: pass
upgraded = True
# 🌟 messages.timeline 列(agent 时间线 JSON: 思考/文本/工具 按事件顺序)
try:
cursor.execute("SELECT timeline FROM messages LIMIT 1")
except sqlite3.OperationalError:
print("[DB]: 正在升级 messages 表 (添加 timeline)...")
try: cursor.execute("ALTER TABLE messages ADD COLUMN timeline TEXT")
except: pass
upgraded = True
# 🆕 P1: messages.usage 列(assistant 回复的精确 usage JSON
# 供显示/压缩估算做 usage 锚定,对照 pi 内存态 usage 回放)
try:
cursor.execute("SELECT usage FROM messages LIMIT 1")
except sqlite3.OperationalError:
print("[DB]: 正在升级 messages 表 (添加 usage)...")
try: cursor.execute("ALTER TABLE messages ADD COLUMN usage TEXT")
except: pass
upgraded = True
# 🆕 失败轮次持久化(对照 pi: message_end 无条件入库):
# messages.stop_reason / error_message —— 区分「正常行 / 出错行」,
# 供 UI 展示与 build_api_context 回放决策(NULL = 正常)
#
# ⚠️⚠️ 绝不能置 upgraded=True:该标志会触发下方的「旧数据链表化重构」,
# 把用户的**树状分支拍平成线性链**(数据破坏)!
# 纯追加列对本迁移自身而言是安全的,与旧库结构修复无关。
try:
cursor.execute("SELECT stop_reason, error_message FROM messages LIMIT 1")
except sqlite3.OperationalError:
print("[DB]: 正在升级 messages 表 (添加 stop_reason, error_message)...")
try: cursor.execute("ALTER TABLE messages ADD COLUMN stop_reason TEXT")
except: pass
try: cursor.execute("ALTER TABLE messages ADD COLUMN error_message TEXT")
except: pass
# 如果刚才执行了树状结构升级,立即对旧数据进行“时间线串联”修复
if upgraded:
print("[DB]: 🚀 正在执行旧数据链表化重构...")
cursor.execute("SELECT id FROM sessions")
sessions = cursor.fetchall()
for s in sessions:
sid = s['id']
cursor.execute("SELECT id FROM messages WHERE session_id = ? ORDER BY created_at ASC", (sid,))
msgs = cursor.fetchall()
if not msgs: continue
# 遍历消息,将后一条的 parent_id 指向上一条
prev_id = None
for m in msgs:
mid = m['id']
if prev_id:
cursor.execute("UPDATE messages SET parent_id = ? WHERE id = ?", (prev_id, mid))
prev_id = mid
# 最后一个 msg_id 就是这棵树的末端叶子节点
cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev_id, sid))
print("[DB]: ✅ 数据结构完美升级为链表树!")
def _seed_default_chat(self, cursor):
session_id = f"sess_{uuid.uuid4().hex[:12]}"
now = int(time.time())
cursor.execute(
"INSERT INTO sessions (id, title, created_at, updated_at, has_messages) VALUES (?, ?, ?, ?, ?)",
(session_id, "让我们从这里开始吧", now, now, 1)
)
sys_id = f"msg_sys_init"
cursor.execute("""
INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (sys_id, session_id, "system", "你是一个优秀的助手!", "", 0, now, None))
default_messages = [
{"role": "user", "content": "你好呀,你是谁?"},
{"role": "assistant", "content": "嗨!我是你的 AI 助手 ✨ ..."},
{"role": "user", "content": "那你到底能帮我做什么?"},
{"role": "assistant", "content": "简单来说,能打字问的我都聊..."}
]
prev_id = sys_id
for msg in default_messages:
msg_id = f"msg_{uuid.uuid4().hex[:16]}"
now += 1
cursor.execute("""
INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (msg_id, session_id, msg["role"], msg["content"], "", 0, now, prev_id))
prev_id = msg_id
# 设置默认会话的叶子节点
cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev_id, session_id))
# ==================== 会话 (Session) 操作 ====================
def get_session_mode(self, session_id: str) -> Optional[str]:
"""读取会话锁定的模式(chat/worker),未发送过返回 None"""
with self.get_connection() as conn:
row = conn.execute("SELECT mode FROM sessions WHERE id = ?",
(session_id,)).fetchone()
return row["mode"] if row else None
def set_session_mode(self, session_id: str, mode: str):
"""锁定会话模式(首条消息发送时调用,之后不可变)"""
with self.get_connection() as conn:
conn.execute("UPDATE sessions SET mode = ? WHERE id = ?",
(mode, session_id))
def get_all_sessions(self) -> List[Dict]:
with self.get_connection() as conn:
return [dict(row) for row in conn.execute("SELECT * FROM sessions ORDER BY is_starred DESC, sort_order ASC, updated_at DESC").fetchall()]
def create_session(self, title: str = "新对话") -> Dict:
session_id = f"sess_{uuid.uuid4().hex[:12]}"
now = int(time.time())
sys_msg_id = f"msg_{uuid.uuid4().hex[:16]}"
with self.get_connection() as conn:
cursor = conn.cursor()
min_order = self.get_min_sort_order()
# 创建 session,直接将 system 消息设为初始叶子节点
cursor.execute(
"INSERT INTO sessions (id, title, created_at, updated_at, has_messages, sort_order, current_leaf_msg_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
(session_id, title, now, now, 0, min_order, sys_msg_id)
)
cursor.execute("""
INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (sys_msg_id, session_id, "system", "你是一个优秀的助手!", "", 0, now, None))
conn.commit()
return dict(cursor.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone())
# ------------------------------------------------------------------
# 🆕 会话复制:深度克隆(全部分支 + 压缩标记 + 附件文件)
# ------------------------------------------------------------------
def _make_copy_title(self, base: str) -> str:
"""生成不重名的副本标题:X → X (副本) → X (副本 2) → …"""
root = re.sub(r"\s*\(副本(?:\s*\d+)?\)\s*$", "", base or "").strip() or "新对话"
with self.get_connection() as conn:
existing = {r[0] for r in conn.execute("SELECT title FROM sessions").fetchall()}
cand = f"{root} (副本)"
n = 2
while cand in existing and n < 1000:
cand = f"{root} (副本 {n})"
n += 1
return cand
def _dup_media_file(self, old_path: str, prefix: str):
"""把一个项目内媒体文件复制成新名字。
返回 (新绝对路径, 新项目相对路径);文件不存在或异常返回 None。"""
if not old_path:
return None
old_abs = old_path if os.path.isabs(old_path) else os.path.join(self.files_root, old_path)
if not os.path.isfile(old_abs):
return None
ext = os.path.splitext(old_abs)[1] or ".bin"
new_dir = os.path.join(self.files_root, "data", "attachments")
os.makedirs(new_dir, exist_ok=True)
new_abs = ""
for _ in range(5):
new_abs = os.path.join(new_dir, f"{prefix}_{uuid.uuid4().hex[:8]}{ext}")
if not os.path.exists(new_abs):
break
shutil.copy2(old_abs, new_abs)
new_rel = os.path.relpath(new_abs, self.files_root).replace("\\", "/")
return new_abs, new_rel
def _copy_attachment_files(self, meta_json: Optional[str]) -> Optional[str]:
"""附件深拷贝:image/pdf 的磁盘文件复制改名并改写 local_path;
text 类型正文内联在 JSON 里,无需处理。
任何异常/文件缺失都原样返回,绝不让复制整体失败。"""
if not meta_json:
return meta_json
try:
meta = json.loads(meta_json)
except Exception:
return meta_json
if not isinstance(meta, dict) or not meta.get("attachments"):
return meta_json
changed = False
for att in meta.get("attachments") or []:
if not isinstance(att, dict):
continue
try:
if att.get("type") in ("image", "pdf") and att.get("local_path"):
got = self._dup_media_file(
att["local_path"], "img" if att["type"] == "image" else "pdf")
if got:
att["local_path"] = got[1]
if "abs_path" in att:
att["abs_path"] = got[0]
changed = True
if att.get("type") == "pdf":
for im in att.get("images") or []:
if not isinstance(im, dict):
continue
got = self._dup_media_file(
im.get("abs_path") or im.get("local_path"), "pdfimg")
if got:
im["abs_path"] = got[0]
im["local_path"] = got[1]
changed = True
except Exception as e:
print(f"[DB] ⚠️ 附件深拷贝失败(保留原路径): {e}", flush=True)
return json.dumps(meta, ensure_ascii=False) if changed else meta_json
def copy_session(self, session_id: str, new_title: Optional[str] = None,
copy_attachments: bool = True) -> Optional[Dict]:
"""📋 深度复制一个会话。
- messages 全部重新生成 IDparent_id / current_leaf_msg_id 全量重映射
→ 分支、压缩标记(role='compaction')都原样保留
- image/pdf 附件文件物理复制成新文件 → 副本自包含,删任意一方不影响另一方
- 单事务写入;源会话零改动
返回新会话 dict;源不存在返回 None。
"""
now = int(time.time())
n_att = 0
with self.get_connection() as conn:
cursor = conn.cursor()
src_row = cursor.execute(
"SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not src_row:
return None
src = dict(src_row)
rows = [dict(r) for r in cursor.execute(
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, rowid ASC",
(session_id,)).fetchall()]
# ① 新 ID 映射(保持 comp_ / msg_ 前缀约定)
idmap = {}
for m in rows:
pre = "comp_" if m.get("role") == "compaction" else "msg_"
idmap[m["id"]] = f"{pre}{uuid.uuid4().hex[:16]}"
# ② 标题(重名自动递增)
title = new_title or self._make_copy_title(src.get("title") or "新对话")
# ③ 消息 created_at 保相对间隔(同层兄弟排序不变)
t0 = min((m.get("created_at") or 0) for m in rows) if rows else now
new_sid = f"sess_{uuid.uuid4().hex[:12]}"
new_leaf = idmap.get(src.get("current_leaf_msg_id"))
if rows and not new_leaf:
# 兜底:源叶子不在链上(数据异常)→ 取副本里时间最新的一条
last = max(rows, key=lambda m: (m.get("created_at") or 0))
new_leaf = idmap.get(last["id"])
print(f"[DB] ⚠️ copy_session 源叶子异常,回退 leaf={new_leaf}", flush=True)
# ④ 新会话(置列表顶部、不带星标、模式跟随源)
cursor.execute(
"INSERT INTO sessions (id, title, created_at, updated_at, has_messages, "
"sort_order, is_starred, current_leaf_msg_id, mode) VALUES (?,?,?,?,?,?,?,?,?)",
(new_sid, title, now, now, src.get("has_messages") or 0,
self.get_min_sort_order(), 0, new_leaf, src.get("mode")))
# ⑤ 逐条复制消息(parent 重映射 + 附件深拷贝)
for m in rows:
meta = m.get("attachment_metadata")
if copy_attachments and meta:
new_meta = self._copy_attachment_files(meta)
if new_meta != meta:
n_att += 1
meta = new_meta
cursor.execute(
"INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, "
"created_at, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(idmap[m["id"]], new_sid, m.get("role"), m.get("content"),
m.get("reasoning"), m.get("is_ignored"),
now + ((m.get("created_at") or 0) - t0),
meta, idmap.get(m.get("parent_id")),
m.get("timeline"), m.get("usage"),
m.get("stop_reason"), m.get("error_message")))
conn.commit()
out = dict(cursor.execute(
"SELECT * FROM sessions WHERE id = ?", (new_sid,)).fetchone())
try:
print(f"[DB] copy_session {session_id[:8]}{new_sid[:8]} "
f"消息={len(rows)} 附件深拷贝={n_att} 标题={title}", flush=True)
except Exception:
pass
return out
def update_session_title(self, session_id: str, new_title: str):
with self.get_connection() as conn:
conn.execute("UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?", (new_title, int(time.time()), session_id))
conn.commit()
def delete_session(self, session_id: str):
with self.get_connection() as conn:
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
conn.commit()
# 下方其它基本Session功能保持不变...
def mark_session_has_messages(self, session_id: str):
with self.get_connection() as conn:
conn.execute("UPDATE sessions SET has_messages = 1 WHERE id = ?", (session_id,))
conn.commit()
def check_session_needs_title(self, session_id: str) -> bool:
with self.get_connection() as conn:
row = conn.execute("SELECT title, has_messages FROM sessions WHERE id = ?", (session_id,)).fetchone()
if row: return row[0] == "新对话" and row[1] == 1
return False
def get_min_sort_order(self) -> int:
with self.get_connection() as conn:
row = conn.execute("SELECT MIN(sort_order) FROM sessions").fetchone()
return (row[0] or 0) - 1
def update_session_order(self, ordered_ids: list):
with self.get_connection() as conn:
for idx, sid in enumerate(ordered_ids):
conn.execute("UPDATE sessions SET sort_order = ? WHERE id = ?", (idx, sid))
conn.commit()
def update_session_star(self, session_id: str, is_starred: bool):
with self.get_connection() as conn:
conn.execute("UPDATE sessions SET is_starred = ?, updated_at = ? WHERE id = ?", (1 if is_starred else 0, int(time.time()), session_id))
conn.commit()
def is_session_starred(self, session_id: str) -> bool:
with self.get_connection() as conn:
row = conn.execute("SELECT is_starred FROM sessions WHERE id = ?", (session_id,)).fetchone()
return bool(row and row[0])
# ==================== 🌟 核心:消息链表树操作 ====================
def get_session_leaf(self, session_id: str) -> Optional[str]:
"""获取会话当前的叶子节点ID"""
with self.get_connection() as conn:
row = conn.execute("SELECT current_leaf_msg_id FROM sessions WHERE id = ?", (session_id,)).fetchone()
return row[0] if row else None
def update_session_leaf(self, session_id: str, leaf_msg_id: str):
"""切换时间线:手动更新当前会话的叶子节点"""
with self.get_connection() as conn:
conn.execute("UPDATE sessions SET current_leaf_msg_id = ?, updated_at = ? WHERE id = ?",
(leaf_msg_id, int(time.time()), session_id))
conn.commit()
def get_message_chain(self, session_id: str) -> List[Dict]:
"""🚀 极客级递归拉取:顺藤摸瓜,只返回当前激活时间线上的消息!彻底断绝下游污染!"""
leaf_id = self.get_session_leaf(session_id)
if not leaf_id:
return []
chain = []
with self.get_connection() as conn:
cursor = conn.cursor()
current_id = leaf_id
seen = set() # 🐛 环检测:数据异常(如自环标记)时快速退出,防主线程死循环卡死
# 使用 Python 循环向上回溯(比 SQL CTE 更好调试,性能对于本地几千条聊天来说在 1ms 内)
while current_id and current_id not in seen:
seen.add(current_id)
cursor.execute("SELECT * FROM messages WHERE id = ?", (current_id,))
msg = cursor.fetchone()
if not msg:
break
chain.append(dict(msg))
current_id = msg['parent_id']
if current_id in seen:
try:
print(f"[DB] ⚠️ get_message_chain 检测到环(session={session_id}),已截断", flush=True)
except Exception:
pass
# 因为是向上回溯,拉出来的链条是反的,最后翻转一下恢复正序
chain.reverse()
return chain
def get_branch_info(self, parent_id: str) -> List[Dict]:
"""获取某一父节点下的所有子分支消息 (第二阶段用于UI渲染 '1/3')"""
if not parent_id: return []
with self.get_connection() as conn:
return [dict(row) for row in conn.execute(
"SELECT * FROM messages WHERE parent_id = ? ORDER BY created_at ASC", (parent_id,)
).fetchall()]
def add_message(self, session_id: str, role: str, content: str, parent_id: str,
reasoning: str = "", is_ignored: bool = False,
msg_id: Optional[str] = None, attachment_metadata: Optional[str] = None,
timeline: Optional[str] = None,
usage: Optional[str] = None,
stop_reason: Optional[str] = None,
error_message: Optional[str] = None) -> Dict:
"""添加新消息,并自动将该消息设为当前会话的最新叶子节点
🆕 P1: usage —— assistant 回复的精确 usage JSON(如 '{"input":..,"output":..}'
🆕 失败轮次: stop_reason/error_message —— 'error' 行入库但不回退叶子
(对照 pi:错误也持久化,回放时由 build_api_context 决定取舍)"""
if not msg_id: msg_id = f"msg_{uuid.uuid4().hex[:16]}"
now = int(time.time())
with self.get_connection() as conn:
cursor = conn.cursor()
# 1. 插入消息
cursor.execute("""
INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (msg_id, session_id, role, content, reasoning, 1 if is_ignored else 0, now, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message))
# 2. 自动更新 session 的叶子节点(时间线前推)
cursor.execute("UPDATE sessions SET current_leaf_msg_id = ?, updated_at = ? WHERE id = ?",
(msg_id, now, session_id))
conn.commit()
try:
print(f"[DB] add_message role={role} 内容={len(content or '')}c "
f"思考={len(reasoning or '')}c 时间线={'' if timeline else ''} "
f"id={msg_id} session={session_id[:8]}", flush=True)
except Exception:
pass
return dict(cursor.execute("SELECT * FROM messages WHERE id = ?", (msg_id,)).fetchone())
# ------------------------------------------------------------------
# 🆕 压缩持久化:链上标记点(子叶子回溯到标记即停)
# 插入后链形:…→ cut_before → [MARK role="compaction"] → first_retained → …→叶子
# 历史行全部保留(前端切会话渲染照常);API 上下文构建在标记处截断。
# 关键:不动 sessions.current_leaf_msg_id(绝不能用 add_message);
# INSERT + UPDATE 同一事务,不留断链窗口。
# ------------------------------------------------------------------
def insert_compaction_mark(self, session_id: str, summary: str,
cut_before_id: str, first_retained_id: str,
meta_json: Optional[str] = None) -> Optional[str]:
"""在链上插入压缩标记行并把保留首条的 parent_id 改指到标记。返回 mark_id。"""
if not cut_before_id or not first_retained_id:
return None
if cut_before_id == first_retained_id:
# 🐛 防自环:同一行不能既做切点前又做保留首条(timeline 回放同 id 场景)
try:
print(f"[DB] ⚠️ insert_compaction_mark 拒绝自环 cut==retained={cut_before_id}", flush=True)
except Exception:
pass
return None
mark_id = f"comp_{uuid.uuid4().hex[:16]}"
now = int(time.time())
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""INSERT INTO messages
(id, session_id, role, content, reasoning, is_ignored,
created_at, attachment_metadata, parent_id, timeline, usage)
VALUES (?, ?, 'compaction', ?, '', 1, ?, ?, ?, NULL, NULL)""",
(mark_id, session_id, summary, now, meta_json, cut_before_id))
cursor.execute("UPDATE messages SET parent_id = ? WHERE id = ?",
(mark_id, first_retained_id))
conn.commit()
try:
print(f"[DB] insert_compaction_mark session={session_id[:8]} "
f"cut_before={cut_before_id} first_retained={first_retained_id} "
f"mark={mark_id} summary={len(summary or '')}c", flush=True)
except Exception:
pass
return mark_id
# === 在 db_manager.py 中添加这个方法 ===
def get_branch_leaf(self, msg_id: str) -> str:
"""寻找一条时间线的最末端叶子节点"""
current_id = msg_id
with self.get_connection() as conn:
cursor = conn.cursor()
while True:
# 寻找把当前节点作为父节点的子节点,按时间倒序取最新的一条(意味着它顺着最近被聊过的那条线往下走)
cursor.execute("SELECT id FROM messages WHERE parent_id = ? ORDER BY created_at DESC LIMIT 1", (current_id,))
child = cursor.fetchone()
if child:
current_id = child[0]
else:
break # 没有子节点了,它自己就是叶子!
return current_id
def delete_message_branch(self, session_id: str, msg_id: str):
"""🚀 精准剪枝:删AI只删当前分支,删User连根拔起,并自动平滑回退时间线"""
with self.get_connection() as conn:
cursor = conn.cursor()
# 1. 查出要删除的节点的父亲
cursor.execute("SELECT parent_id FROM messages WHERE id = ?", (msg_id,))
row = cursor.fetchone()
if not row: return
safe_parent_id = row['parent_id']
# 2. 目标就是传进来的 msg_id 本身 (不再强制上移到 parent)
target_id = msg_id
# 3. 拉取全会话,构建亲属关系树
cursor.execute("SELECT id, parent_id FROM messages WHERE session_id = ?", (session_id,))
all_msgs = cursor.fetchall()
children_map = {}
for m in all_msgs:
pid = m['parent_id']
if pid not in children_map:
children_map[pid] = []
children_map[pid].append(m['id'])
# 4. 广度优先搜索 (BFS):找出目标消息及其所有子孙
to_delete = set([target_id])
queue = [target_id]
while queue:
curr = queue.pop(0)
if curr in children_map:
for child in children_map[curr]:
if child not in to_delete:
to_delete.add(child)
queue.append(child)
# 5. 判断当前时间线的“叶子节点”是否在被波及的名单里
cursor.execute("SELECT current_leaf_msg_id FROM sessions WHERE id = ?", (session_id,))
leaf_row = cursor.fetchone()
leaf_needs_update = leaf_row and leaf_row['current_leaf_msg_id'] in to_delete
# 6. 🌟 执行物理删除前,收集将被删除的附件元数据
deleted_metadata = []
for d_id in to_delete:
# 先查出它的 metadata
cursor.execute("SELECT attachment_metadata FROM messages WHERE id = ?", (d_id,))
row = cursor.fetchone()
if row and row['attachment_metadata']:
deleted_metadata.append(row['attachment_metadata'])
# 然后再执行物理删除
cursor.execute("DELETE FROM messages WHERE id = ?", (d_id,))
# 7. 🌟 核心:如果时间线断了,自动寻找平滑降落点
sibling_row = None
if leaf_needs_update:
# 尝试寻找被删节点的最新“兄弟姐妹” (例如删了分支2,寻找分支1)
cursor.execute("SELECT id FROM messages WHERE parent_id = ? ORDER BY created_at DESC LIMIT 1", (safe_parent_id,))
sibling_row = cursor.fetchone()
# 如果有兄弟,降落到兄弟;如果没兄弟(只有1次回答),退回原点(提问)
new_leaf = sibling_row['id'] if sibling_row else safe_parent_id
cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (new_leaf, session_id))
conn.commit()
# 8. 如果降落到了兄弟分支,兄弟可能还有下文,需再次对齐真实叶子节点
if leaf_needs_update and sibling_row:
real_leaf = self.get_branch_leaf(new_leaf)
self.update_session_leaf(session_id, real_leaf)
return deleted_metadata # 🌟 返回被删除的元数据,交给 MainWindow 去粉碎文件
+78
View File
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""统一调试日志 + 调试器窗口控制协议(纯文件通信,与主窗口 UI 零耦合)
日志文件: data/debug_session.log (环境变量 HAOCODE_DEBUG_LOG 可覆盖)
控制文件: data/debug_window.cmd (环境变量 HAOCODE_DEBUG_CMD 可覆盖)
三方写入协议:
[USER] 用户在调试窗口输入框手输的观察情况
[AGENT] 代理(外部脚本/命令行)注入的指令与备注
[APP] 应用自身事件(上下文标签变化/usage/发送/完成/报错/压缩)
[SYS] 调试窗口自身的开关事件
行格式: [YYYY-MM-DD HH:MM:SS.mmm] [TAG] 内容
控制协议: 代理往 debug_window.cmd 写入 "show""hide"(一行),
app 侧 2s QTimer 轮询并消费(读完即删)。
"""
import os
import threading
import time
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_DATA_DIR = os.path.join(_ROOT, "data")
DEBUG_LOG_PATH = (os.environ.get("HAOCODE_DEBUG_LOG")
or os.path.join(_DATA_DIR, "debug_session.log"))
DEBUG_CMD_PATH = (os.environ.get("HAOCODE_DEBUG_CMD")
or os.path.join(_DATA_DIR, "debug_window.cmd"))
_lock = threading.Lock()
def _stamp() -> str:
t = time.time()
return (f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t))}"
f".{int(t * 1000) % 1000:03d}")
def debug_log(msg: str, tag: str = "APP") -> None:
"""线程安全追加一条日志(worker 线程亦可调用)。
静默吞掉一切异常——本模块绝不影响主流程。"""
try:
with _lock:
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
f.write(f"[{_stamp()}] [{tag}] {msg}\n")
except Exception:
pass
def poll_debug_cmd():
"""消费控制文件,返回 'show' / 'hide' / None。由主线程 QTimer 周期调用。"""
try:
if os.path.exists(DEBUG_CMD_PATH):
with open(DEBUG_CMD_PATH, "r", encoding="utf-8") as f:
action = (f.read() or "").strip().lower()
try:
os.remove(DEBUG_CMD_PATH)
except Exception:
pass
if action in ("show", "hide"):
return action
except Exception:
pass
return None
def autostart_debug_window(cfg: dict) -> bool:
"""调试窗口随程序启动:cfg["debug_window_autostart"] 为 true(缺省也是 true
时向控制文件写 "show",主窗口事件循环启动后 2s 轮询即开窗。
纯文件操作(不依赖 Qt),返回是否写入。"""
try:
if not bool(cfg.get("debug_window_autostart", True)):
return False
with open(DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write("show\n")
return True
except Exception:
return False
+446
View File
@@ -0,0 +1,446 @@
"""
core/llm_engine.py
==================
Qt 胶水层 —— 把 core.agentpi 1:1 核心)接到 PyQt6 信号系统。
🌟 对照 pi 架构:
AgentWorker(QThread)
内部持有 Agent + AgentRunner(对照 pi 的 Agent + AgentSession 会话层)
run() = pi 的 _runAgentPromptprompt → 循环 post-agent-run 恢复 → settle
事件桥:AgentEvent → pyqtSignal(跨线程 queued 投递到 UI 线程)
ChatWorker(QThread)
chat 模式:普通聊天单次流式(无工具循环/无重试/无压缩)。
TitleWorker(QThread)
轻量单次流式补全(标题生成),保留原 LLMWorker 的信号面。
信号面(与旧 LLMWorker 兼容 + 新增工具事件):
chunk_received(str) # text_delta
reasoning_received(str) # thinking_delta
error_occurred(str) # 终局错误(已重试/压缩恢复仍失败)
tool_execution_started(str, str, str) # (call_id, tool_name, args_json)
tool_execution_updated(str, str) # (call_id, 执行中增量输出)
tool_execution_finished(str, str, bool, str) # (call_id, tool_name, ok, 结果摘要)
"""
from __future__ import annotations
import json
import os
from typing import Any, Dict, List, Optional
from openai import OpenAI
from PyQt6.QtCore import QThread, pyqtSignal
from core.agent import (Agent, AgentConfig, AgentEvent, AgentMessage, AgentRunner,
ModelConfig, RetryConfig, calculate_context_tokens,
default_tools, from_openai_messages,
openai_stream)
from core.agent.stream_fn import _pick_reasoning, _pick_usage
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "config.json")
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
# 🌟 Agent 系统提示词文件(项目根目录,core 前面那个 .md)
SYSTEM_PROMPT_FILE = os.path.join(PROJECT_ROOT, "SYSTEM_PROMPT.md")
_FALLBACK_SYSTEM_PROMPT = (
"你是 haocode 的本地智能体,可以读取/写入文件并执行 bash 命令"
"(工作目录为项目根目录)。请用简体中文简洁地回答。"
)
def load_system_prompt() -> str:
"""读取 SYSTEM_PROMPT.md;缺失时用兜底短提示词。"""
try:
with open(SYSTEM_PROMPT_FILE, "r", encoding="utf-8") as f:
text = f.read().strip()
if text:
return text
except FileNotFoundError:
pass
return _FALLBACK_SYSTEM_PROMPT
def _load_config() -> dict:
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"[llm_engine] 读取配置失败: {e}")
return {}
def _provider_info(config: dict, provider_name: str) -> dict:
return config.get("providers", {}).get(provider_name, {}) or {}
class AgentWorker(QThread):
"""
一次「发送」= 一个 AgentWorker(对照 pi:一个 AgentSession 实例)。
多轮工具循环在一次 run() 内完成;UI 只管收信号 + 最终入库。
"""
# ---- 与旧 LLMWorker 兼容的信号 ----
chunk_received = pyqtSignal(str)
reasoning_received = pyqtSignal(str)
error_occurred = pyqtSignal(str)
# ---- 新增:工具执行事件(pi tool_execution_* 事件)----
tool_execution_started = pyqtSignal(str, str, str) # (call_id, name, args)
tool_execution_updated = pyqtSignal(str, str) # (call_id, 增量输出)
tool_execution_timed = pyqtSignal(str, int, int) # (call_id, 已耗秒, 超时秒) 🆕 bash 读秒
tool_execution_finished = pyqtSignal(str, str, bool, str) # (call_id, name, ok, 结果)
# 上下文压缩发生(UI 可提示「已自动压缩上下文」)
context_compacted = pyqtSignal(dict)
# 🆕 压缩开始(摘要 LLM 阻塞调用前)→ 前端显示「执行中」动态气泡
# payload: {summary, before, after, duration_ms, path}
compaction_started = pyqtSignal(str) # path: pre_prompt/overflow_compact/length_compact
# 🆕 P1: 收到本轮精确 usage(UI 上下文标签做 usage 锚定,含 system+tools
usage_updated = pyqtSignal(dict)
# 🆕 M3: 重试调度 / 重试结果(对照 pi onRetryScheduled/onRetryFinished
retry_scheduled = pyqtSignal(int, int, float, str) # (attempt, max_attempts, delay_ms, reason)
retry_finished = pyqtSignal(bool)
def __init__(self, provider_name: str, model_name: str,
openai_messages: List[Dict[str, Any]],
tool_context: Optional[Dict[str, Any]] = None,
enable_tools: bool = True,
temperature: Optional[float] = None):
"""
openai_messages: build_api_context 的产物(OpenAI 格式)。
最后一条 user 消息 = 本次提问;其余 = 历史上下文。
"""
super().__init__()
self.provider_name = provider_name
self.model_name = model_name
self.openai_messages = openai_messages
self.tool_context = tool_context or {"cwd": PROJECT_ROOT}
self.enable_tools = enable_tools
self._temperature_override = temperature
self.config = _load_config()
self._aborted = False
self._agent: Optional[Agent] = None
self._runner: Optional[AgentRunner] = None
self._compactions_before = 0
# ------------------------------------------------------------------
# 中断(对照 pi abort:当前流立刻停止,已产生的内容保留)
# ------------------------------------------------------------------
def abort(self):
self._aborted = True
if self._agent is not None:
self._agent.abort()
# 兼容旧调用名
def cancel(self):
self.abort()
# ------------------------------------------------------------------
# 模型配置(对照 pi models.json 条目 → ModelConfig
# ------------------------------------------------------------------
def _build_model_config(self) -> ModelConfig:
p = _provider_info(self.config, self.provider_name)
context_window = int(p.get("model_contexts", {}).get(self.model_name, 128000))
max_tokens = int(p.get("model_max_tokens", {}).get(self.model_name, 8192))
temperature = (self._temperature_override
if self._temperature_override is not None
else float(self.config.get("temperature", 0.7)))
return ModelConfig(
provider=self.provider_name,
name=self.model_name,
context_window=context_window,
max_tokens=max_tokens,
temperature=temperature,
api_key=p.get("api_key", ""),
base_url=p.get("base_url", "https://api.openai.com/v1"),
# 🆕 thinking reasoning_content 回传默认开启(ModelConfig.pass_reasoning=True
)
# ------------------------------------------------------------------
# 压缩用的非流式 LLM 调用(对照 pi completeSimpleWithRetries
# 新签名 1:1 对照 pi(prompt_text, system_prompt, max_tokens) -> str
# ------------------------------------------------------------------
def _make_summarize_fn(self, model: ModelConfig):
def summarize(prompt_text: str, system_prompt: str, max_tokens: int) -> str:
client = OpenAI(api_key=model.api_key, base_url=model.base_url,
timeout=120.0, max_retries=0)
resp = client.chat.completions.create(
model=model.name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt_text},
],
stream=False,
max_tokens=max_tokens,
temperature=0.3,
)
return (resp.choices[0].message.content or "").strip()
return summarize
# ------------------------------------------------------------------
# 事件桥:AgentEvent → Qt 信号(在工作线程内 emit,queued 投递)
# ------------------------------------------------------------------
def _bridge(self, e: AgentEvent):
if e.type == "message_end" and e.message is not None:
# 🆕 P1: 本轮刚结束 → 它的 usage 是「上一张照片」(精确覆盖
# system+tools+历史+本轮输出),供 UI 显示锚定
usage = e.message.usage or {}
if calculate_context_tokens(usage) > 0:
self.usage_updated.emit(dict(usage))
if e.type == "message_update" and e.assistant_message_event is not None:
ev = e.assistant_message_event
# 🌟 字段是 ev.text(不是 ev.data);_emit 会吞掉订阅者异常,
# 写错字段 = 静默无流式输出,必须靠 test_agent_worker_stream_fn_wiring 守住
if ev.type == "text_delta" and ev.text:
self.chunk_received.emit(ev.text)
elif ev.type == "thinking_delta" and ev.text:
self.reasoning_received.emit(ev.text)
return
if e.type == "tool_execution_start" and e.tool_call is not None:
try:
args = json.dumps(e.tool_call.arguments, ensure_ascii=False) \
if e.tool_call.arguments else (e.tool_call.raw_arguments or "")
except Exception:
args = str(e.tool_call.arguments)
self.tool_execution_started.emit(e.tool_call.id, e.tool_call.name,
str(args)[:2000])
elif e.type == "tool_execution_update" and e.arg:
self.tool_execution_updated.emit(e.tool_call.id, str(e.arg))
elif e.type == "tool_execution_timer" and isinstance(
getattr(e, "arg", None), tuple) and len(e.arg) == 2:
# 🆕 bash 运行中每秒滴一次 → 前端气泡读秒
self.tool_execution_timed.emit(e.tool_call.id, int(e.arg[0]),
int(e.arg[1]))
elif e.type == "tool_execution_end" and e.tool_call is not None:
text = ""
if e.result is not None:
c = e.result.content
text = c if isinstance(c, str) else \
"".join(x.get("text", "") for x in c if isinstance(x, dict))
# 🌟 修复: 原来 ok = is_error(反了)——成功的工具被显示成失败
ok = not (e.result.is_error if e.result is not None else True)
self.tool_execution_finished.emit(
e.tool_call.id, e.tool_call.name, bool(ok), text[:20000]) # 长结果供 UI 展开
# ------------------------------------------------------------------
# 主入口 —— 对照 pi _runAgentPrompt
# ------------------------------------------------------------------
def run(self):
provider = _provider_info(self.config, self.provider_name)
if not provider:
self.error_occurred.emit(f"未找到 provider 配置: {self.provider_name}")
return
history = from_openai_messages(self.openai_messages)
if not history or history[-1].role != "user":
self.error_occurred.emit("上下文必须以 user 消息结尾")
return
last_user = history.pop() # 本次提问(其余为历史)
model = self._build_model_config()
cfg = AgentConfig(
model=model,
# 🌟 系统提示词:SYSTEM_PROMPT.md(每次请求头部注入,不入历史)
system_prompt=load_system_prompt() if self.enable_tools else "",
tools=default_tools() if self.enable_tools else [],
tool_context=self.tool_context,
retry=RetryConfig(max_attempts=3, base_delay_ms=2000, factor=2.0),
)
agent = Agent(cfg)
# 🌟 关键:注入流函数(对照 pi 的 agentLoopConfig.streamFn 注入点)。
# 漏掉这一步 Agent 会抛 AgentError("未配置 stream_fn")。
agent.set_stream_fn(openai_stream)
agent.state.messages = list(history)
agent.subscribe(self._bridge)
summarize = self._make_summarize_fn(model)
runner = AgentRunner(agent, summarize_fn=summarize,
# 🆕 M3: 重试回调 → Qt 信号(跨线程 queued 投递)
on_retry_scheduled=lambda a, m, d, r:
self.retry_scheduled.emit(int(a), int(m), float(d), str(r)),
on_retry_finished=lambda ok:
self.retry_finished.emit(bool(ok)),
# 🆕 压缩开始回调 → 前端动态气泡
on_compaction_started=lambda p:
self.compaction_started.emit(str(p)),
# 🆕 压缩完成回调 → 气泡即时定格(不等整轮结束)
on_compaction_finished=lambda p:
self.context_compacted.emit(dict(p)))
# 🆕 接线轮中主动压缩检查(haocode 增强,偏离 pi 1:1):
# 内层循环每次 LLM 请求前主动检查,单条巨型工具输出不再依赖
# 「一次失败往返 + 响应式兜底」
agent.config.compact_fn = runner.compact_if_needed
self._agent = agent
self._runner = runner
self._compactions_before = 0
try:
result = runner.run(last_user)
except Exception as e:
if not self._aborted:
self.error_occurred.emit(f"\n[Agent 异常]: {e}")
return
if self._aborted:
return # 取消路径:UI 已自行清理,不回退时间线
# 注:压缩事件(context_compacted)已由 on_compaction_finished 在压缩结束
# 瞬间实时发出(气泡即时定格),这里不再重复 flush runner.compaction_events
if result.error is not None and result.stop_reason == "error":
# 恢复逻辑(重试 + 压缩)全部用尽后的终局错误
self.error_occurred.emit(
f"\n[API 请求异常]: {result.error.message or '未知错误'}")
# ------------------------------------------------------------------
# 供 UI 读取最终状态(入库用)
# ------------------------------------------------------------------
def final_messages(self) -> List[AgentMessage]:
return list(self._agent.state.messages) if self._agent else []
class ChatWorker(QThread):
"""
chat 模式 —— 普通聊天:单次流式补全(对照旧 LLMWorker 的行为)。
不进入 agent 循环:无工具调用、无重试、无压缩。
worker 模式请用 AgentWorker。
"""
chunk_received = pyqtSignal(str)
reasoning_received = pyqtSignal(str)
error_occurred = pyqtSignal(str)
# 🆕 P1: 收到精确 usageUI 上下文标签 usage 锚定)
usage_updated = pyqtSignal(dict)
def __init__(self, provider_name: str, model_name: str,
openai_messages: List[Dict[str, Any]],
temperature: Optional[float] = None):
super().__init__()
self.provider_name = provider_name
self.model_name = model_name
self.openai_messages = openai_messages
self._temperature_override = temperature
self.config = _load_config()
self._cancelled = False
def cancel(self):
self._cancelled = True
def abort(self):
self._cancelled = True
def run(self):
p = _provider_info(self.config, self.provider_name)
if not p:
self.error_occurred.emit(f"未找到 provider 配置: {self.provider_name}")
return
model = self._build_model_config(p)
max_tokens = int(p.get("model_max_tokens", {}).get(self.model_name, 8192))
try:
client = OpenAI(api_key=p.get("api_key", ""),
base_url=p.get("base_url", "https://api.openai.com/v1"),
timeout=model.timeout_seconds, max_retries=0)
response = client.chat.completions.create(
model=self.model_name,
messages=self.openai_messages,
stream=True,
temperature=model.temperature,
max_tokens=max_tokens,
# 🆕 P1: 拿精确 usage 供上下文标签锚定(对照 pi stream_options
stream_options={"include_usage": True},
)
for chunk in response:
if self._cancelled:
try:
response.close()
except Exception:
pass
return
# 🆕 P1/M2: usage 读取(含 choice 兜底)
_u = _pick_usage(chunk)
if _u is not None and (
getattr(_u, "prompt_tokens", 0) or getattr(_u, "completion_tokens", 0)):
self.usage_updated.emit({
"input": getattr(_u, "prompt_tokens", 0) or 0,
"output": getattr(_u, "completion_tokens", 0) or 0,
"cacheRead": 0, "cacheWrite": 0,
})
if not chunk.choices:
continue
delta = chunk.choices[0].delta
# 🆕 M1: 思考字段优先级对照 pireasoning_content/reasoning/reasoning_text
_r = _pick_reasoning(delta)
if _r:
self.reasoning_received.emit(_r)
if delta.content:
self.chunk_received.emit(delta.content)
except Exception as e:
if not self._cancelled:
self.error_occurred.emit(str(e))
def _build_model_config(self, p: dict) -> ModelConfig:
context_window = int(p.get("model_contexts", {}).get(self.model_name, 128000))
temperature = (self._temperature_override
if self._temperature_override is not None
else float(self.config.get("temperature", 0.7)))
return ModelConfig(
provider=self.provider_name,
name=self.model_name,
context_window=context_window,
max_tokens=int(p.get("model_max_tokens", {}).get(self.model_name, 8192)),
temperature=temperature,
api_key=p.get("api_key", ""),
base_url=p.get("base_url", "https://api.openai.com/v1"),
# 🆕 thinking reasoning_content 回传默认开启
)
class TitleWorker(QThread):
"""标题生成:单次流式补全(保留原信号面)"""
chunk_received = pyqtSignal(str)
error_occurred = pyqtSignal(str)
def __init__(self, provider_name: str, model_name: str,
openai_messages: List[Dict[str, Any]]):
super().__init__()
self.provider_name = provider_name
self.model_name = model_name
self.openai_messages = openai_messages
self.config = _load_config()
self._cancelled = False
def cancel(self):
self._cancelled = True
def run(self):
p = _provider_info(self.config, self.provider_name)
if not p:
return
try:
client = OpenAI(api_key=p.get("api_key", ""),
base_url=p.get("base_url", "https://api.openai.com/v1"),
timeout=60.0, max_retries=0)
temperature = float(self.config.get("temperature", 0.7))
response = client.chat.completions.create(
model=self.model_name,
messages=self.openai_messages,
stream=True,
temperature=temperature,
max_tokens=60,
)
for chunk in response:
if self._cancelled:
try:
response.close()
except Exception:
pass
return
if chunk.choices:
delta = chunk.choices[0].delta
if delta.content:
self.chunk_received.emit(delta.content)
except Exception as e:
if not self._cancelled:
self.error_occurred.emit(str(e))
+406
View File
@@ -0,0 +1,406 @@
# -*- coding: utf-8 -*-
"""
WebView2 后端(Windows 首选浏览器内核,失败自动回落 QtWebEngine)。
设计要点(来自 P1 实验 tests/_tmp_wv2_demo.py 的实测结论):
1. pythonnet + WebView2 .NET SDKvendor/webview2/ 内 net462 Core.dll + webview2loader_x64.dll
2. 必须 OleInitializeSTA)后才能 CreateAsync
3. 本机的 SDK 怪癖:传任何非空 user_data_folder 都报 RuntimeNotFound → 一律用默认 profileud=None
4. 残留 msedgewebview2.exe 会锁默认 profile0x800700AA)→ 初始化前 taskkill
5. .NET 版 CoreWebView2 不暴露子窗口 HWND → EnumChildWindows 找 Chrome_WidgetWin_* 类
6. 子窗口天然是 Qt 顶层窗口的子 HWND,不需要 QWindow.fromWinId 包装(对子窗口会失败),
由 Qt 布局算 slot 矩形后用 SetBoundsAndZoomFactor 驱动(父窗客户区物理像素)
7. JS→Python 用 WebMessageReceivedJSON),Python→JS 用 ExecuteScriptAsync(与现有
ChatBridge.run_js 生成的 JS 调用文本完全同构,前端零改动;仅 index.html 的
window.bridge bootstrap 走双协议)
"""
import os
import sys
import time
import shutil
import subprocess
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WV2_DIR = os.path.join(ROOT, "vendor", "webview2")
CORE_DLL = os.path.join(WV2_DIR, "net462_Microsoft.Web.WebView2.Core.dll")
LOADER = os.path.join(WV2_DIR, "webview2loader_x64.dll")
_env = None
_System = None # pythonnet 加载后缓存
_INSTANCE_LOCK = {"fh": None}
def _instance_lock_path():
# 可覆盖(测试隔离用:HAOCODE_INSTANCE_LOCK_FILE 指向临时文件,
# 否则测试会与正在运行的 app 争同一把锁 → 断言依赖环境)
p = os.environ.get("HAOCODE_INSTANCE_LOCK_FILE")
if p:
return p
d = os.path.join(ROOT, "data")
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return os.path.join(d, "app_instance.lock")
def acquire_instance_lock():
"""独占 data/app_instance.lock(非阻塞)。
返回 True = 本机唯一实例 → 可安全 taskkill 残留 msedgewebview2.exe
False = 已有实例在跑 → 必须回落 QtWebEngine
None = 平台不支持/异常(保守当“不确定”处理)
🐛 T0 根因:本函数存在之前,每个 MainWindow()(包括 offscreen 测试)都会走到
_get_environment() 里的 `taskkill /F /IM msedgewebview2.exe`
把【当时正在运行的生产 app】的 WebView2 浏览器进程一并杀掉
→ 它的 controller 变 disposedset_bounds 报 0x8007139F
→ DOM 照渲染但视觉层永久空白(= “选中会话不渲染核心内容”)。
"""
if sys.platform != "win32":
return None
if _INSTANCE_LOCK["fh"] is not None:
return True
fh = None
try:
import msvcrt
fh = open(_instance_lock_path(), "a+b")
fh.seek(0) # 固定锁位置(锁定前 1 字节),不依赖 append 模式的当前位置
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
_INSTANCE_LOCK["fh"] = fh
return True
except Exception:
try:
if fh is not None:
fh.close()
except Exception:
pass
return False
def _wv2_allowed_here() -> bool:
"""无头/测试环境一律不得碰 WebView2(共享默认 profile,一碰就可能误杀在生产实例)"""
if os.environ.get("HAOCODE_FORCE_QTWEBENGINE", "") in ("1", "true", "True"):
return False
plat = (os.environ.get("QT_QPA_PLATFORM") or "").strip().lower()
if plat and plat != "windows":
return False
return True
def _pump_wait(op, app, timeout=15.0):
"""在 Qt 事件循环里等 .NET Task 完成(WebView2 初始化依赖消息泵)"""
t0 = time.time()
while not op.IsCompleted and time.time() - t0 < timeout:
app.processEvents()
time.sleep(0.005)
if not op.IsCompleted:
raise TimeoutError("WebView2 operation timed out")
if op.IsFaulted:
raise RuntimeError(str(op.Exception))
return op.Result
def _ts():
"""启动链时间戳(WebView2 冷启动耗时诊断,保留)"""
t = time.time()
return time.strftime("%H:%M:%S", time.localtime(t)) + f".{int(t*1000) % 1000:03d}"
def get_environment(app):
"""初始化并返回 CoreWebView2Environment 单例;任何失败返回 None(调用方回落 QtWebEngine"""
global _env
if _env is not None:
return _env
if sys.platform != "win32":
return None
if not (os.path.exists(CORE_DLL) and os.path.exists(LOADER)):
return None
# 🛡 守卫 1:无头/测试环境(QT_QPA_PLATFORM=offscreen 等)绝不启用 WebView2
if not _wv2_allowed_here():
print(f"[WV2] QT_QPA_PLATFORM={os.environ.get('QT_QPA_PLATFORM')!r} → 跳过 WebView2"
f"回落 QtWebEngine(无头环境不得触碰共享 profile)")
return None
# 🛡 守卫 2:本机已有实例在跑 → 不启用 WebView2、更不 taskkill(否则会把它的
# 浏览器进程杀掉 → 对方 controller disposed → 聊天区永久空白)
try:
_lock_ok = acquire_instance_lock()
except Exception:
_lock_ok = None
if _lock_ok is not True:
print("[WV2] 检测到已有 haocode 实例在运行(instance lock 被占)→ "
"本实例回落 QtWebEngine;已跳过 taskkill,不会影响对方渲染")
return None
try:
os.environ["PATH"] = WV2_DIR + ";" + os.environ.get("PATH", "")
try:
os.add_dll_directory(WV2_DIR)
except Exception:
pass
# .NET Core 的 P/Invoke 默认不查 CWD → 把 loader 复制到 CWD 一份
try:
cwd_loader = os.path.join(os.getcwd(), "WebView2Loader.dll")
if not os.path.exists(cwd_loader):
shutil.copyfile(LOADER, cwd_loader)
except Exception:
pass
import ctypes
ctypes.windll.ole32.OleInitialize(None) # STACOM 初始化要求)
# 清残留浏览器进程(锁默认 profile 会导致 0x800700AA
# 🛡 只有【本机唯一实例】才会走到这里(已在上面用 instance lock 保证),
# 否则会把兄弟实例的浏览器进程杀掉 → 对方控制器 disposed → 聊天区空白
print(f"[WV2] {_ts()} warmup: taskkill 残留进程(唯一实例,安全)...")
try:
subprocess.run(["taskkill", "/F", "/IM", "msedgewebview2.exe"],
capture_output=True, timeout=10)
time.sleep(1.0)
except Exception:
pass
import clr
global _System
clr.AddReference(CORE_DLL)
import System
_System = System
from Microsoft.Web.WebView2.Core import CoreWebView2Environment
# ⚠️ ud=None:本机 SDK 对非空 user_data 路径报 RuntimeNotFound(实测怪癖)
_env = _pump_wait(CoreWebView2Environment.CreateAsync(None, None, None), app)
print(f"[WV2] {_ts()} Runtime ready: {_env.BrowserVersionString}")
return _env
except Exception as ex:
_env = None
print(f"[WV2] init failed → fallback to QtWebEngine: {ex}")
return None
class Wv2Session:
"""一个 WebView2 实例:controller + 子窗口 + 消息泵 + bounds 驱动"""
def __init__(self, env, parent_hwnd, app):
self.app = app
print(f"[WV2] {_ts()} controller create begin (hwnd={parent_hwnd:#x})")
self.controller = _pump_wait(
env.CreateCoreWebView2ControllerAsync(_System.IntPtr(int(parent_hwnd))), app)
print(f"[WV2] {_ts()} controller ready")
self.core = self.controller.CoreWebView2
try:
self.core.Settings.AreDefaultContextMenusEnabled = False
self.core.Settings.IsZoomControlEnabled = False
self.core.Settings.IsStatusBarEnabled = False
except Exception:
pass
self.child_hwnd = 0
self.on_message = None # callable(dict) —— main_window 绑定到 ChatBridge
self.on_load_finished = None # callable() —— WebView2View 绑定 loadFinished 信号
self._msg_handler = self._make_msg_handler()
self.core.add_WebMessageReceived(self._msg_handler)
self._nw_handler = self._make_navigated_handler()
self.core.add_NavigationCompleted(self._nw_handler)
# 🆕 真异步 JS 执行队列:单一定时器轮询完成,回调从主线程定时器发出。
# (旧版 execute_js_async 是阻塞忙等 + processEvents → 从定时器/事件回调里调用
# 时产生重入嵌套事件循环 → COM 事件分发崩溃 → 启动卡死/拖动时 webview 不刷新)
self._js_pending = [] # [task, cb, t0]
from PyQt6.QtCore import QTimer
self._js_pump = QTimer()
self._js_pump.setInterval(25)
self._js_pump.timeout.connect(self._js_pump_tick)
self._js_pump.start()
# 🆕 预热:立即导航 about:blank,让 msedgewebview2 进程/GPU 在 UI 构建期间冷启动
# (实测本机首次真实页面导航需 12-15s,预热后降到 ~1s)
try:
print(f"[WV2] {_ts()} warmup: Navigate about:blank")
self.core.Navigate("about:blank")
except Exception as ex:
print("[WV2] warmup navigate error:", ex)
# ---------- 事件 ----------
def _make_msg_handler(self):
import json
from System import EventHandler
from Microsoft.Web.WebView2.Core import CoreWebView2WebMessageReceivedEventArgs
def handler(sender, args):
try:
data = json.loads(args.WebMessageAsJson)
if self.on_message:
self.on_message(data)
except Exception as ex:
print("[WV2] message error:", ex)
return EventHandler[CoreWebView2WebMessageReceivedEventArgs](handler)
def _make_navigated_handler(self):
from System import EventHandler
from Microsoft.Web.WebView2.Core import CoreWebView2NavigationCompletedEventArgs
def handler(sender, args):
try:
src = self.core.Source or ""
except Exception:
src = "?"
print(f"[WV2] {_ts()} NavigationCompleted src={src}")
# 预热页(about:blank)的加载完成不触发 loadFinished(避免 JS 探针空转)
if src.startswith("about:blank"):
return
if self.on_load_finished:
self.on_load_finished()
return EventHandler[CoreWebView2NavigationCompletedEventArgs](handler)
# ---------- 子窗口发现(.NET 不暴露 HWND,轮询枚举) ----------
def find_child_once(self):
"""单次探测;找到返回 hwnd,否则 0(非阻塞,供 UI 线程定时器调)"""
import ctypes
import ctypes.wintypes as wt
u32 = ctypes.windll.user32
parent = wt.HWND(self.controller.ParentWindow.ToInt64())
found = []
@ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPVOID)
def cb(h, _):
buf = ctypes.create_unicode_buffer(256)
u32.GetClassNameW(h, buf, 256)
if buf.value.startswith("Chrome_WidgetWin"):
found.append(h)
return True
u32.EnumChildWindows(parent, cb, None)
if found:
self.child_hwnd = int(found[0])
return self.child_hwnd
return 0
def find_child(self):
"""同步等待并返回 Chrome_WidgetWin_* 子 HWND(最多 10s"""
import ctypes
import ctypes.wintypes as wt
u32 = ctypes.windll.user32
parent = wt.HWND(self.controller.ParentWindow.ToInt64())
for _ in range(200):
found = []
@ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPVOID)
def cb(h, _):
buf = ctypes.create_unicode_buffer(256)
u32.GetClassNameW(h, buf, 256)
if buf.value.startswith("Chrome_WidgetWin"):
found.append(h)
return True
u32.EnumChildWindows(parent, cb, None)
if found:
self.child_hwnd = int(found[0])
return self.child_hwnd
self.app.processEvents()
time.sleep(0.05)
return 0
# ---------- 几何 ----------
def set_visible(self, visible: bool):
try:
self.controller.IsVisible = bool(visible)
except Exception:
pass
def set_bounds(self, left, top, width, height):
"""父窗客户区物理像素"""
try:
from System.Drawing import Rectangle as _Rect
self.controller.SetBoundsAndZoomFactor(_Rect(int(left), int(top),
int(width), int(height)), 1.0)
except Exception as ex:
print("[WV2] set_bounds error:", ex)
def child_size(self):
"""子窗口当前屏幕像素尺寸(观察用)"""
if not self.child_hwnd:
return (0, 0)
import ctypes
import ctypes.wintypes as wt
r = wt.RECT()
if ctypes.windll.user32.GetWindowRect(wt.HWND(self.child_hwnd), ctypes.byref(r)):
return (r.right - r.left, r.bottom - r.top)
return (0, 0)
# ---------- JS ----------
def navigate(self, url: str):
print(f"[WV2] {_ts()} navigate {url[:80]}")
try:
self.core.Navigate(url)
except Exception as ex:
print("[WV2] navigate error:", ex)
def execute_js(self, script: str):
"""fire-and-forgetChatBridge.run_js 的替换,JS 文本完全同构)"""
self._js_run(script, None)
def execute_js_async(self, script: str, cb):
"""带回调执行:真异步,回调在主线程定时器 tick 中发出(绝不阻塞)"""
self._js_run(script, cb)
def _js_run(self, script: str, cb):
try:
task = self.core.ExecuteScriptWithResultAsync(script)
except Exception as ex:
print("[WV2] execute_js error:", ex)
if cb:
try:
cb(None)
except Exception:
pass
return
self._js_pending.append([task, cb, time.time()])
def _js_pump_tick(self):
if not self._js_pending:
return
import json
remaining = []
for task, cb, t0 in self._js_pending:
done = False
try:
done = task.IsCompleted
except Exception:
done = True
if not done and time.time() - t0 > 10:
done = True # 10s 安全超时(渲染器死亡时不永久卡队列)
if not done:
remaining.append([task, cb, t0])
continue
result = None
try:
if task.IsCompleted and not task.IsFaulted:
r = task.Result # CoreWebView2ExecuteScriptResult 包装结构
if getattr(r, "Succeeded", True):
s = r.ResultAsJson # JSON 编码字符串(或 null
if s:
result = json.loads(s)
if isinstance(result, str):
try:
result = json.loads(result)
except Exception:
pass
except Exception as ex:
print("[WV2] js result decode error:", ex)
if cb:
try:
cb(result)
except Exception as ex:
print("[WV2] js callback error:", ex)
self._js_pending = remaining
def close(self):
try:
self._js_pump.stop()
except Exception:
pass
self._js_pending = []
try:
self.core.remove_WebMessageReceived(self._msg_handler)
except Exception:
pass
try:
self.core.remove_NavigationCompleted(self._nw_handler)
except Exception:
pass
try:
self.controller.Close()
except Exception:
pass
+111
View File
@@ -0,0 +1,111 @@
# main.py
import io
import os
import sys
# ==================== 🌟 冻结/无控制台环境:stdout/stderr 安全化 ====================
# 🐛 打包成 exe 后踩到的真 bug(开发环境永远不会暴露):
# 打包版没有控制台,sys.stdout 可能是 None 或编码为 GBK 的流 →
# 任何 print 里的 emoji(🚀✅🛑…)都会抛 UnicodeEncodeError
# 而且是在 MainWindow() 之前就崩 → **主窗口根本不出现**。
# 统一改成 UTF-8 + errors=replace:打包版与开发版行为一致,
# 即使控制台是 GBK,也只会显示成 '?' 而不会崩。
for _name in ("stdout", "stderr"):
_s = getattr(sys, _name, None)
if _s is None:
try:
setattr(sys, _name,
open(os.devnull, "w", encoding="utf-8", errors="replace"))
except Exception:
pass
continue
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
try:
setattr(sys, _name, io.TextIOWrapper(
_s.buffer, encoding="utf-8", errors="replace", line_buffering=True))
except Exception:
pass
# ==================== 🌟 WebEngine 渲染配置(必须在 PyQt6 导入之前设置)====================
# 本机实测渲染模式:GPU 硬件加速(ANGLE → Direct3D11, AMD 核显),非 CPU 软渲染。
#
# 黑边/黑框背景(已实测):
# Windows 上窗口缩放的新暴露区域未出帧时,GPU 合成器露出黑色、软件合成器露出白色。
# 基准数据(重度页面 500 节点+滚动+强制重绘):
# GPU 合成 59.2 FPS(黑框)
# 软件合成 30.1 FPS(白框,视觉反差小;轻量聊天页实测跟手无差别)
# 因此默认采用“软件合成”(白框、好看);若遇重度页面掉帧,用 HAOCODE_COMPOSITING=gpu 回退。
#
# ⚠️ 2026-07 重大变更:默认改为全 CPU 软渲染(--disable-gpu)。
# 原因:本机 AMD 核显 GPU 上下文周期性丢失(context lost 实测复现),
# GPU 光栅化产物无法合成上屏 → 流式正文 DOM 已写入但屏幕不刷新。
# 软渲染在 Chromium 内全 CPU 完成,彻底绕开该故障模式;聊天页性能足够。
#
# 备选开关(二选一,或直接设置 QTWEBENGINE_CHROMIUM_FLAGS 完全覆盖):
# HAOCODE_RENDER=gpu → 旧默认:GPU 光栅化 + 软合成(需 GPU 驱动稳定)
# HAOCODE_COMPOSITING=gpu → 回退 GPU 合成(黑框、满帧)
if "QTWEBENGINE_CHROMIUM_FLAGS" not in os.environ:
_render_mode = os.environ.get("HAOCODE_RENDER", "").strip().lower()
_comp_mode = os.environ.get("HAOCODE_COMPOSITING", "soft").strip().lower()
if _render_mode in ("gpu", "hardware"):
# 旧默认:GPU 光栅化 + 软合成(仅当 GPU 驱动稳定时使用)
_flags = ["--enable-gpu-rasterization", "--enable-zero-copy", "--ignore-gpu-blocklist"]
if _comp_mode != "gpu": # 默认软合成:黑框→白框
_flags.append("--disable-gpu-compositing")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = " ".join(_flags)
else:
# 默认(含 software):全 CPU 软渲染,规避 GPU 上下文丢失导致的不刷新
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
print(f"[渲染] QTWEBENGINE_CHROMIUM_FLAGS = {os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', '(未设置)')}", flush=True)
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import Qt
# 确保 Python 能找到项目根目录下的模块
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# 导入主窗口类
from ui.views.main_window import MainWindow
def main():
# 1. 开启高 DPI 缩放支持 (让界面在 2K/4K 屏幕上不模糊)
# PyQt6 默认已经处理得很好了,但加上这句更稳妥
if hasattr(Qt, 'AA_EnableHighDpiScaling'):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
# 2. 初始化 QApplication 实例
app = QApplication(sys.argv)
# 极客细节:设置应用的全局字体 (可选)
# font = app.font()
# font.setFamily("Segoe UI") # Windows 推荐字体
# app.setFont(font)
# 防御:若系统解析出的默认字体 pointSize 无效(<=0),补一个合法值,
# 消除启动时 "QFont::setPointSize: Point size <= 0 (-1)" 的 Qt 警告(纯警告,非异常)
_app_font = app.font()
if _app_font.pointSize() <= 0:
_app_font.setPointSize(9)
app.setFont(_app_font)
print("🚀 GeekAgent-Studio 正在启动...")
print("--------------------------------------------------")
# 3. 实例化主窗口
window = MainWindow()
# 4. 显示窗口
window.show()
# 5. 进入主事件循环,并安全退出
sys.exit(app.exec())
if __name__ == "__main__":
main()
+301
View File
@@ -0,0 +1,301 @@
# haocode
> Windows 桌面 AI Agent 客户端 —— **PyQt6 外壳 + 本地 Web 渲染(WebView2 优先 / QtWebEngine 兜底)
> + 与 [pi](https://github.com/badlogic/pi) **1:1 对齐的 agent 核心**(工具调用 / 自动重试 / 上下文压缩)。**
能读写文件、执行 bash、带图片与 PDF 附件聊天、把「思考 / 工具调用 / 工具结果」按时间线持久化,
并在此基础上支持会话树分支、会话复制、实时上下文计量与右侧任务面板。
---
## 一、快速开始
### 1. 环境要求
| 项 | 要求 |
|---|---|
| 操作系统 | Windows 10 / 11 x64 |
| Python | **3.10**(开发环境:miniconda env `haocode` |
| WebView2 Runtime | Win11 自带;Win10 需装 [Evergreen Runtime](https://developer.microsoft.com/microsoft-edge/webview2/)(缺失会自动回落 QtWebEngine,仍可用) |
| Node.js | 可选,只有跑 `tests/test_math_extract.js` 需要 |
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
> **不需要** pip 安装的运行时依赖(已随仓库提供,勿删):
> `vendor/webview2/`WebView2 .NET SDK + loader)、根目录 `WebView2Loader.dll`。
### 3. 配置 API Key
编辑 `data/config.json`
```jsonc
{
"providers": {
"deepseek": {
"api_key": "sk-填你自己的", // ← 交接版已清空,必须填
"base_url": "https://api.deepseek.com",
"models": ["deepseek-v4-flash"],
"model_contexts": { "deepseek-v4-flash": 1000000 } // 决定上下文用量条与压缩阈值
}
},
"default_provider": "deepseek",
"default_model": "deepseek-v4-flash",
"debug_window_autostart": true, // 启动时是否自动开「调试窗口」
"mode_switch": true, // 是否允许会话中途切换 chat ↔ worker
"webview_backend": "auto" // auto | webview2 | qtwebengine
}
```
### 4. 运行
```bash
python main.py
```
首次运行会自动在 `data/` 下创建**空的** `chat_history.db`(含一条初始对话)。
左侧栏「+ 新建对话」开始聊天;输入框上方可贴图/拖文件/截屏(Ctrl+Shift+S)。
### 5. 打包 exe(可选)
```bash
python -m PyInstaller haocode.spec --distpath "输出目录" --noconfirm
```
- 产物为 **onedir**(一个文件夹,里面 `haocode.exe` 双击即用)
- **不含数据库**:首次运行在 exe 同级 `data/` 自建空库
- 冻结版没有控制台,启动日志写 **exe 同级 `haocode.log`**`pyi_rth_trace.py` 钩子)
- ⚠️ 打包前务必确认:`main.py` 顶部的 stdout/stderr UTF-8 保护存在(否则 `print` 里的 emoji 会在 GBK 环境直接崩,表现为"双击没反应")
---
## 二、架构总览
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ① 表现层 ui/views/*.py (PyQt6) │
│ main_window.py 主窗口 / bash_panel.py 右侧任务面板 / debug_window.py │
└───────────────┬──────────────────────────────────────────────────────────┘
│ QWebChannelchat_bridge.pyPython → JS 单向调用)
┌───────────────▼──────────────────────────────────────────────────────────┐
│ ② 渲染层 ui/web/index.html + app.js + style.css + KaTeX/highlight
│ 容器二选一:wv2_view.py(WebView2) / custom_web_page.py(QtWebEngine) │
└───────────────▲──────────────────────────────────────────────────────────┘
│ 事件信号(Qt Signal/Slot,跨线程 queued
┌───────────────┴──────────────────────────────────────────────────────────┐
│ ③ 引擎层 core/llm_engine.py │
│ AgentWorker(worker 模式) / ChatWorker(chat 模式) / TitleWorker(标题) │
└───────────────┬──────────────────────────────────────────────────────────┘
┌───────────────▼──────────────────────────────────────────────────────────┐
│ ④ Agent 核心 core/agent/pi 1:1 移植) │
│ loop.py 循环 → stream_fn.py 流式+异常分类 → recovery.py 重试/压缩决策 │
│ tools.py 四个工具(read/bash/write/edit · compaction.py 上下文压缩 │
│ context.py 上下文组装与计量 · types.py 数据结构 │
└───────────────┬──────────────────────────────────────────────────────────┘
┌───────────────▼──────────────────────────────────────────────────────────┐
│ ⑤ 存储层 core/db_manager.pySQLite:会话树 / 消息 / 压缩标记 / 附件) │
└──────────────────────────────────────────────────────────────────────────┘
```
**一句话数据流**
`输入框 → main_window 组装上下文(core/agent/context) → AgentWorker 跑 agent 循环 →
LLM 流式返回(stream_fn) → 工具在本地执行(tools) → 事件经 chat_bridge 推到 Web 层渲染 →
结束时整轮(正文/思考/工具时间线/usage)落库(db_manager),并成为会话树的新叶子`
---
## 三、目录树(每行说明这是什么)
```
haocode/
├── main.py 程序入口:渲染开关 → UTF-8 保护 → QApplication → MainWindow
├── SYSTEM_PROMPT.md ★ agent 系统提示词(每次请求注入,不进历史、不占压缩)
├── requirements.txt 依赖清单(只列代码真实用到的包)
├── haocode.spec PyInstaller 打包配置(onedir / 不含 db / 带启动日志钩子)
├── pyi_rth_trace.py 打包运行时钩子:冻结版 stdout+stderr → exe 同级 haocode.log
├── WebView2Loader.dll WebView2 loader(工作目录兜底路径)
├── core/ ★ 后端核心(无界面依赖)
│ ├── db_manager.py SQLite 存储层:会话/消息链表树/压缩标记/附件/复制会话
│ ├── llm_engine.py Qt 线程桥:AgentWorker / ChatWorker / TitleWorker + 配置与提示词加载
│ ├── webview2.py WebView2 集成:pythonnet 环境、单实例锁、无头守卫、残留进程清理
│ ├── debug_log.py 调试事件总线(供调试窗口「对话消息 / 应用日志」两页)
│ └── agent/ ★ pi 1:1 agent 核心(9 个文件,见 Frame.md
│ ├── agent.py Agent 对象:状态、事件订阅、prompt/continue、steering 队列
│ ├── loop.py agent 主循环:流式一轮 → 工具批执行 → 轮末钩子 → 停止判定
│ ├── stream_fn.py OpenAI 流式调用 + 异常分类(classify_error
│ ├── recovery.py 重试/压缩三路决策(对照 pi retry.ts / overflow.ts
│ ├── compaction.py 上下文压缩(摘要切点、保留尾巴、标记生成)
│ ├── context.py 上下文组装、token 估算、usage 锚定
│ ├── tools.py 四个工具实现:read / bash / write / edit+ 参数校验)
│ └── types.py ModelConfig / AgentConfig / AgentMessage / AgentTool… 数据结构
├── ui/ ★ 表现层
│ ├── views/ PyQt6 窗口与逻辑
│ │ ├── main_window.py ★主窗口(6000 行,全项目最核心):布局/样式/流式状态/附件/入库
│ │ ├── bash_panel.py 右侧「任务面板」:运行中/已完成两栏、层卡片、拖拽调宽
│ │ ├── chat_bridge.py QWebChannel 桥:把 Python 调用翻译成 JS 函数调用
│ │ ├── wv2_view.py WebView2 容器控件(QWidget + 原生子窗口)
│ │ ├── custom_web_page.py QtWebEngine 容器控件(回落路径,含 QWebChannel 注入)
│ │ ├── debug_window.py 调试窗口:事件流 + 应用日志 + 命令输入
│ │ └── system_tools/ 系统级能力(见 Frame.md
│ │ ├── file_reader.py 文本/代码文件读取与二进制黑名单
│ │ ├── global_hotkey.py 全局热键注册(Ctrl+Shift+S 截屏)
│ │ └── screen_capture.py 区域截屏
│ └── web/ 本地 Web 渲染层(离线,无 CDN)
│ ├── index.html 页面骨架 + 三个本地库的引入
│ ├── app.js ★前端全部逻辑:消息渲染/流式增量/时间线/KaTeX/滚动
│ ├── style.css 全部样式(气泡/思考块/工具 chip/紧凑模式)
│ ├── marked.min.js Markdown 渲染
│ ├── dompurify.min.js HTML 消毒(配合 marked
│ ├── highlight/ 代码高亮(highlight.js + atom-one-dark
│ └── katex/ 公式渲染(KaTeX + 字体)
├── data/ 运行时数据目录
│ ├── config.json 供应商/模型/开关配置(★ 需填 API Key)
│ ├── chat_history.db 聊天主库(**不在交接版内**,首次运行自建空库)
│ └── (运行后还会出现) debug_session.log · app_instance.lock · attachments/ · wv2_cache/
├── vendor/webview2/ WebView2 .NET SDKnet462 Core.dll + webview2loader_x64.dll
├── svg/ 界面图标 18 个(panel.svg 左栏 / panel_right.svg 右栏 / 模式图标…)
├── tools/builtin_tools/
│ └── pdf_reader.py PDF 解析(extract_pdf_text / extract_pdf_images,被主窗口 import
├── tests/ ★ 测试与调试工具(见第五节)
└── 历史文档(保留备查,非最新结构说明)
├── ARCHITECTURE.md 早期架构文档(其中部分目录已在交接版移除)
├── readme_our.md 早期设计/规划稿(973 行)
└── 黑边两现象分析报告.md Windows 窗口缩放黑边问题的分析记录
```
---
## 四、我要改 X,该去哪个文件?(功能 → 文件索引)
| 需求 | 文件 | 关键位置 |
|---|---|---|
| 改主界面布局 / 气泡 / 输入框 / 主题 | `ui/views/main_window.py` | `setup_ui()``setup_stylesheet()` |
| 改前端渲染(Markdown / 公式 / 流式增量) | `ui/web/app.js` + `style.css` | `marked.parse` 统一拦截点、`updateMessage` |
| 右侧任务面板(层卡片/状态) | `ui/views/bash_panel.py` | `BashPanel` / `BashLayer` / `_Section` |
| Python 调前端 JS | `ui/views/chat_bridge.py` | 每个方法 = 一个 JS 函数 |
| 数据库结构 / 会话树 / 附件 | `core/db_manager.py` | `add_message``get_message_chain``copy_session` |
| **系统提示词** | `SYSTEM_PROMPT.md` | 直接改,每次请求重新读取(无需重启) |
| 工具的实现与参数校验 | `core/agent/tools.py` | `tool_read` / `tool_bash` / `tool_write` / `tool_edit` |
| 工具调用循环 / 停止条件 | `core/agent/loop.py` | `run_agent_loop``execute_tool_calls` |
| 重试与压缩策略 | `core/agent/recovery.py` | `_handle_post_agent_run`(三路决策) |
| 上下文压缩算法 | `core/agent/compaction.py` | `compact_context``should_compact` |
| token 估算 / usage 锚定 | `core/agent/context.py` | `estimate_context_tokens``calculate_context_tokens` |
| 模型请求 / 流式解析 / 错误分类 | `core/agent/stream_fn.py` | `openai_stream``classify_error` |
| WebView2 行为(锁/无头/清场) | `core/webview2.py` | `get_environment``acquire_instance_lock` |
| 调试窗口 | `ui/views/debug_window.py` + `core/debug_log.py` | — |
| 打包 | `haocode.spec` + `pyi_rth_trace.py` | — |
---
## 五、测试方式
### 1. 两条铁律(写新测试必须遵守)
1. **不得污染真实数据库**:测试启动时必须把 `core.db_manager._DEFAULT_DB` 指向临时文件
```python
import core.db_manager as _dbm
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"t_{os.getpid()}.db")
# 必须在 import MainWindow 之前
```
2. **不得污染真实配置**(会写 `config.json` 的功能):把 `HAOCODE_CONFIG_FILE` 指向临时文件
### 2. 跑测试(建议先设两个环境变量)
```bat
set PYTHONIOENCODING=utf-8
set QT_QPA_PLATFORM=offscreen :: 只有带 GUI 的 smoke_* 需要
```
**A. 纯逻辑测试(无 GUI,秒级)**
```bash
python tests/run_tests.py # agent 核心(流式/工具/重试/压缩) 41 项
python tests/test_tool_params.py # 四个工具的参数校验与错误串 35 项
python tests/test_compaction_persist.py # 压缩标记持久化与上下文截断 41 项
python tests/test_copy_session.py # 会话复制(含附件深拷贝、分支、标题递增) 54 项
python tests/test_bash_stream.py # bash 实时输出/超时杀进程树/50KB 截断 30 项
python tests/test_error_persist.py # 失败轮次入库与回放取舍(含旧库迁移安全) 39 项
python tests/test_wv2_guard.py # WebView2 双守卫(无头/多实例) 10 项
python tests/test_debug_window.py # 调试窗口 22 项
python tests/test_think_code_neutral.py # 思考块/代码块中性化 10 项
python tests/test_file_attach.py # 附件类型判定
python tests/test_pdf_reader.py # PDF 文本/图片解析
node tests/test_math_extract.js # 公式提取(前端 JS 逻辑) 39 项
```
**B. GUI 离屏端到端(需 `QT_QPA_PLATFORM=offscreen`**
```bash
python tests/smoke_offscreen.py # 主窗口起得来 + 三个基础链路 8 项
python tests/smoke_mode.py # chat ↔ worker 模式切换 16 项
python tests/smoke_copy_session.py # 复制会话 UI 全链路
python tests/smoke_bash_panel.py # 右侧任务面板全链路(含折叠/拖拽调宽) 116 项
```
**C. 需要真机/真 API 的(默认不用跑)**
`tests/smoke_live_guard.py`、`smoke_midswitch.py`、`smoke_persist.py`、`smoke_probe.py`、
`smoke_repro_real.py`、`smoke_timeline.py`、`smoke_manual.py`、`diag_live_*.py`、`verify_onscreen.py`
—— 这些是开发期在真窗口/真模型上跑的工具,交接后按需使用。
### 3. 基线(本次交接版实测全绿)
```
run_tests 41/41 · tool_params 35/35 · compaction 41/41 · copy_session 54/54
bash_stream 30/30 · error_persist 39/39 · wv2_guard 10/10 · debug_window 22/22
think_code_neutral 10/10 · math_extract 39/39
smoke_offscreen 8/8 · smoke_mode 16/16 · smoke_copy_session 全通过 · smoke_bash_panel 116/116
```
### 4. 辅助工具
```bash
python tests/check_db_migration.py <某个 .db 或备份 .zip>
# 只读校验:迁移(新增列)后旧库数据是否 100% 原样保留(消息数/父指针/叶子/分支点逐项比对)
```
---
## 六、已知限制
1. **仅 Windows**WebView2 与 `bash` 工具都按 Windows 语义实现(cmd.exe + taskkill /T)。
2. **`bash` 工具的真实 shell 是 cmd.exe**(不是 git-bash):`;` 不是命令分隔符、
`$VAR` 不展开、`cd` 不跨命令保持 —— 系统提示词里已写明这些陷阱与正确写法。
3. **HTTP 代理/流式**:模型请求走 openai SDK,单次 180s 超时,失败按 2s/4s/8s 重试 3 次。
4. **打包体积**:默认带 QtWebEngine 兜底,产物约 575 MB;若确定只用 WebView2
可从 `haocode.spec` 去掉 `PyQt6.QtWebEngine*` 的 `collect_all`(降到约 110 MB)。
5. `data/attachments/` 里的图片/PDF 是**文件系统**资源,删库不会删它们;删会话时才会连带清理。
6. 冻结版与源码版会共用同一个库的**唯一例外**:把 exe 放在源码树的 `dist/` 下时
`exe/../../data/` 存在即共用 `chat_history.db`);放到桌面等其它位置则用 exe 同级 `data/`。
7. **运行时会往项目根目录写诊断产物**(开发期排障用,可直接删):
`compaction_diag.log`(压缩决策日志)、`stream_diag.log`(前端流式体检)、
`diag_shot_*.png`(每轮回复结束时的画面快照)、`data/debug_session.log`(调试窗口日志)。
不想要可自行注释 `ui/views/main_window.py` 里的 `diag_log()` / `diag_shot` 调用点。
---
## 七、交接版说明(haocode_0 相对原项目做了什么)
| 动作 | 内容 |
|---|---|
| ✅ 保留 | 全部源码、`SYSTEM_PROMPT.md`、`vendor/webview2/`、18 个 svg、`ui/web/`(含 KaTeX/highlight)、正式测试套件、`haocode.spec` + 打包钩子 |
| 🗑 删除·空文件 | `agents/`3 个全空)、`workspace/`4 个全空)、`tools/{registry,conda_env}.py`、`tools/builtin_tools/{file_ops,web_search}.py`、`core/{async_sync,memory_manager,prompt_templates}.py`、`ui/views/components.py`、`ui/assets/`(空 style.qss)、`tests/{test_ast,test_replace}.py`、根目录 0 字节文件 —— **均已确认零引用**(`__init__.py` 属于包结构标记,全部保留) |
| 🗑 删除·测试产物 | 根目录 `diag_shot_*.png`(36)、`*.log`、`_fadechk2.py`、`inspect_*.py`、`read_all_py.py`、`ssh_helper.py`、`tmp_timeout_probe.py`、`flowkit.db`、`stress_report.json` 等;`tests/_tmp*`81 项,含 `_tmp_resize_vis/` 与全部临时日志) |
| 🗑 删除·数据库 | `data/chat_history.db` 及全部 `.bak/.pre_clean`、`data/attachments/`、`data/debug_session.log`、`data/wv2_cache/` → **首次运行自动新建空库(含初始对话)** |
| 🗑 删除·其它 | `__pycache__/` 全部、旧封装 `geekagent.spec`、未使用的 `untitled.ui` |
| ✏️ 重写 | `requirements.txt`AST 扫描核对)、`readme.md`(本文件)、`Frame.md`(逐目录细节) |
| ⚠️ 注意 | `data/config.json` 的 `api_key` **已清空**(交接安全),请填入自己的 Key;`.txt` 文本示例按你的要求**全部保留**(`_out.txt`、`_t0.txt` |
> 详细到"每个子文件夹/每个文件干什么、关键类与函数叫什么",见 **`Frame.md`**。
+45
View File
@@ -0,0 +1,45 @@
# haocode 运行依赖
# ---------------------------------------------------------------------------
# 环境:Windows 10/11 x64 + Python 3.10(开发环境为 miniconda env: haocode
# 安装:pip install -r requirements.txt
# 说明:本清单只列**代码真实 import 到**的第三方包(已用 AST 全量扫描核对),
# 不含开发机上其它项目的包(numpy / matplotlib / pillow / paramiko 等均未使用)。
# ---------------------------------------------------------------------------
# ---- GUI 框架(PyQt6----
PyQt6==6.10.2
PyQt6-Qt6==6.10.2
# ---- 渲染回退:QtWebEngineWebView2 不可用 / 无头 / 多实例时自动使用)----
# 注:WebView2 是首选渲染器,但它依赖微软运行时 + vendor/webview2/ 里的 .NET SDK
# 不走 pipQtWebEngine 是本项目自带的兜底渲染路径,必须装。
PyQt6-WebEngine==6.10.0
PyQt6-WebEngine-Qt6==6.10.2
# ---- 大模型 SDKOpenAI 兼容接口;DeepSeek / opencode-go / 自建反代均走它)----
openai==2.26.0
# ---- WebView2 绑定:pythonnetimport clr+ 其加载器 ----
pythonnet==3.1.0
clr_loader==0.3.1
# ---- PDF 附件解析(ui/views/main_window.py → tools/builtin_tools/pdf_reader.py----
PyMuPDF==1.28.0
# ===========================================================================
# 以下仅在需要时安装
# ===========================================================================
# ---- 打包 exe(可选)----
# PyInstaller==6.21.0
# pyinstaller-hooks-contrib==2026.6
# ===========================================================================
# 不需要 pip 安装的运行时依赖(重要)
# ===========================================================================
# 1) vendor/webview2/ WebView2 .NET SDKCore.dll+ webview2loader_x64.dll
# 已随仓库提供,勿删;缺失 → 自动回落 QtWebEngine
# 2) WebView2Loader.dll 项目根目录的 loader(CWD 兜底路径)
# 3) WebView2 Runtime(系统级) Win11 自带;Win10 需安装 Evergreen Runtime
# 4) Node.js 仅 tests/test_math_extract.js 需要(可选)
# 5) pytest 不需要!tests/run_tests.py 自带 harness 并 stub 掉 pytest
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" role="img" aria-label="正确">
<path d="M14 24L21 31L34 17" stroke="#22C55E" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12">
<!-- 展开态箭头(朝下):圆头描边 chevron -->
<path d="M3.2 4.2 L6 7.4 L8.8 4.2" fill="none" stroke="#98a1b3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12">
<!-- 收起态箭头(朝右):圆头描边 chevron -->
<path d="M4.2 3.2 L7.4 6 L4.2 8.8" fill="none" stroke="#98a1b3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" role="img" aria-label="错误">
<path d="M14 14L34 34M34 14L14 34" stroke="#EF4444" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

+1
View File
@@ -0,0 +1 @@
<svg t="1787580199384" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9953" width="200" height="200"><path d="M266.3 750.6C70.3 715.1 67 476.9 67 476.9c-136.8 207.7-40.5 398.6 79.4 472.9 28.7 16.9 59.1 28.7 89.5 30.4 153.7 11.8 172.3-70.9 172.3-70.9s54-123.3-141.9-158.7zM77.1 296.2c-1.7 5.1-1.7 11.8-3.4 16.9-3.4 23.6-3.4 45.6-1.7 67.6 3.4-22 8.4-43.9 15.2-64.2l5.1-15.2c42.2-79.4 108.1-150.3 192.5-201-1.7-5.1-3.4-11.8-8.4-16.9C242.6 44.6 148 32.7 90.6 88.5 33.2 144.2 33.2 248.9 67 287.8c3.3 3.4 6.7 6.7 10.1 8.4zM718.9 96.9c1.7 0 1.7 1.7 3.4 1.7 86.1 43.9 157.1 108.1 204.4 184.1 10.1 0 20.3-5.1 28.7-15.2 28.7-40.5 20.3-145.2-40.5-195.9-62.6-52.4-153.8-33.8-185.9 6.7-1.7 0-3.4 0-5.1-1.7-11.8-1.7-23.6-3.4-33.8-5.1-23.6-1.7-45.6-1.7-67.6 0 22 1.7 43.9 6.8 65.9 13.5 10.2 3.5 20.4 6.9 30.5 11.9zM450.4 644.2c0 8.4 27 27 60.8 27s60.8-16.9 60.8-27c0-10.1-27-18.6-60.8-16.9-33.8 0-60.8 6.7-60.8 16.9zM798.3 559.7c42.2-22 59.1-136.8 13.5-222.9s-116.5-104.7-174-74.3c-57.4 30.4-81.1 101.3-33.8 185.8 45.7 86.1 152.1 135.1 194.3 111.4zM675 355.3c18.6 0 33.8 15.2 33.8 33.8 0 18.6-15.2 33.8-33.8 33.8-18.6 0-33.8-15.2-33.8-33.8 0-18.6 15.2-33.8 33.8-33.8zM401.4 262.4C344 232 273 250.6 227.4 336.8c-45.6 86.1-28.7 201 15.2 222.9 42.2 23.6 148.6-25.3 194.2-111.5 44-84.4 22-153.7-35.4-185.8z m-38.9 160.5c-18.6 0-33.8-15.2-33.8-33.8 0-18.6 15.2-33.8 33.8-33.8 18.6 0 33.8 15.2 33.8 33.8 0 18.6-15.2 33.8-33.8 33.8zM825.3 885.7s-185.8-42.2-211.1-47.3c-25.3-3.4-43.9 13.5-42.2 28.7 0 0-5.1 27 52.4 32.1 59 6.7 200.9-13.5 200.9-13.5zM803.4 600.2S511.2 762.3 472.4 786c-37.2 23.6-42.2 67.6-20.3 82.8 0 0 23.6 40.5 106.4-18.6 82.7-59.1 244.9-250 244.9-250z" fill="#2c2c2c" p-id="9954"></path><path d="M958.8 483.7s-5.1 238.1-202.7 271.9c-86.1 15.2-123.3 45.6-140.2 77.7 25.3 3.4 204.4 45.6 211.1 47.3l43.9 10.1-43.9 3.4c-5.1 0-111.5 15.2-177.3 15.2-10.1 0-18.6 0-27-1.7-5.1 0-10.1-1.7-13.5-1.7 1.7 3.4 3.4 5.1 3.4 5.1s18.6 82.8 170.6 72.6c153.6-10.1 342.8-239.8 175.6-499.9z" fill="#2c2c2c" p-id="9955"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788797540643" class="icon" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6475" xmlns:xlink="http://www.w3.org/1999/xlink" width="200.1953125" height="200"><path d="M304.607147 576c-17.066667 0-34.133333-8.533333-46.933334-17.066667-12.8-12.8-17.066667-29.866667-17.066666-46.933333 0-17.066667 8.533333-34.133333 17.066666-46.933333 4.266667-4.266667 12.8-12.8 21.333334-12.8 17.066667-8.533333 34.133333-8.533333 46.933333 0 8.533333 4.266667 12.8 8.533333 21.333333 12.8 12.8 12.8 17.066667 29.866667 17.066667 46.933333 0 17.066667-8.533333 34.133333-17.066667 46.933333-8.533333 8.533333-25.6 17.066667-42.666666 17.066667zM513.673813 576c-8.533333 0-17.066667 0-25.6-4.266667-8.533333-4.266667-12.8-8.533333-21.333333-12.8-12.8-12.8-17.066667-29.866667-17.066667-46.933333 0-17.066667 8.533333-34.133333 17.066667-46.933333 25.6-25.6 68.266667-25.6 89.6 0 12.8 12.8 17.066667 29.866667 17.066667 46.933333 0 17.066667-8.533333 34.133333-17.066667 46.933333-8.533333 4.266667-12.8 12.8-21.333333 12.8s-12.8 4.266667-21.333334 4.266667zM722.74048 576c-8.533333 0-17.066667 0-25.6-4.266667-8.533333-4.266667-12.8-8.533333-21.333333-12.8-12.8-12.8-17.066667-29.866667-17.066667-46.933333 0-17.066667 8.533333-34.133333 17.066667-46.933333 25.6-25.6 68.266667-25.6 89.6 0 12.8 12.8 17.066667 29.866667 17.066666 46.933333 0 17.066667-8.533333 34.133333-17.066666 46.933333-8.533333 4.266667-12.8 12.8-21.333334 12.8s-12.8 4.266667-21.333333 4.266667z" fill="#2c2c2c" p-id="6476"></path><path d="M44.34048 972.8c-12.8 0-25.6-4.266667-34.133333-17.066667-8.533333-12.8-12.8-29.866667-8.533334-42.666666l64-170.666667C23.007147 669.866667 1.673813 593.066667 1.673813 512 1.673813 260.266667 232.073813 51.2 513.673813 51.2S1025.673813 260.266667 1025.673813 512s-230.4 460.8-512 460.8c-81.066667 0-162.133333-17.066667-230.4-46.933333l-230.4 42.666666c-4.266667 4.266667-4.266667 4.266667-8.533333 4.266667z m243.2-132.266667c4.266667 0 12.8 0 17.066667 4.266667 59.733333 29.866667 132.266667 42.666667 204.8 42.666667 234.666667 0 426.666667-166.4 426.666666-375.466667s-192-375.466667-426.666666-375.466667S87.007147 307.2 87.007147 512c0 72.533333 21.333333 136.533333 64 200.533333 8.533333 12.8 8.533333 25.6 4.266666 38.4l-46.933333 123.733334 170.666667-34.133334h8.533333z" fill="#2c2c2c" p-id="6477"></path></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788830687889" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8728" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M814.933333 1009.066667c-38.4 0-74.666667-14.933333-104.533333-42.666667l-258.133333-256 76.8-76.8 12.8 12.8 29.866666-29.866667 46.933334 46.933334-46.933334 46.933333 196.266667 196.266667c23.466667 23.466667 64 23.466667 89.6 0l53.333333-53.333334c25.6-25.6 25.6-64 0-89.6l-196.266666-196.266666-46.933334 46.933333-46.933333-46.933333 29.866667-29.866667-10.666667-14.933333 76.8-76.8 256 258.133333c57.6 57.6 57.6 151.466667 0 209.066667l-53.333333 53.333333c-29.866667 29.866667-66.133333 42.666667-104.533334 42.666667zM328.533333 490.666667l-85.333333-83.2-113.066667-59.733334L6.4 189.866667 196.266667 0l157.866666 123.733333 59.733334 115.2 89.6 87.466667-59.733334 61.866667-130.133333-130.133334 10.666667-8.533333-36.266667-70.4-85.333333-64-81.066667 81.066667 64 85.333333 70.4 36.266667 8.533333-10.666667 123.733334 123.733333z" p-id="8729"></path><path d="M179.2 1009.066667c-29.866667 0-57.6-10.666667-81.066667-34.133334l-53.333333-53.333333c-44.8-44.8-44.8-117.333333 0-162.133333l452.266667-452.266667c-12.8-83.2 12.8-166.4 72.533333-226.133333C650.666667 0 772.266667-19.2 874.666667 34.133333l51.2 27.733334-149.333334 149.333333 32 32 149.333334-149.333333 27.733333 51.2c53.333333 102.4 34.133333 224-46.933333 305.066666-59.733333 59.733333-145.066667 85.333333-226.133334 72.533334L260.266667 977.066667c-21.333333 21.333333-51.2 32-81.066667 32z m576-917.333334c-44.8 0-89.6 17.066667-123.733333 51.2-44.8 44.8-61.866667 108.8-44.8 168.533334l6.4 23.466666L106.666667 821.333333c-10.666667 10.666667-10.666667 29.866667 0 40.533334l53.333333 53.333333c10.666667 10.666667 29.866667 10.666667 40.533333 0l486.4-486.4 23.466667 6.4c59.733333 17.066667 125.866667-2.133333 168.533333-44.8 38.4-38.4 57.6-93.866667 51.2-145.066667L810.666667 364.8 657.066667 213.333333l119.466666-119.466666c-6.4-2.133333-14.933333-2.133333-21.333333-2.133334z" p-id="8730"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg t="1788704740994" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7644" width="200" height="200"><path d="M789.333333 345.6c12.8 21.333333 4.266667 46.933333-12.8 59.733333l-226.133333 140.8v256c0 25.6-17.066667 42.666667-42.666667 42.666667s-42.666667-17.066667-42.666666-42.666667v-251.733333L230.4 409.6c-21.333333-12.8-25.6-38.4-12.8-59.733333 12.8-21.333333 38.4-25.6 59.733333-17.066667L512 469.333333l221.866667-140.8c17.066667-8.533333 42.666667 0 55.466666 17.066667z" fill="#707070" p-id="7645"></path><path d="M913.066667 230.4L554.666667 25.6c-25.6-17.066667-59.733333-17.066667-85.333334 0L110.933333 230.4c-25.6 17.066667-42.666667 42.666667-42.666666 72.533333v413.866667c0 29.866667 17.066667 59.733333 42.666666 72.533333l358.4 204.8c25.6 17.066667 59.733333 17.066667 85.333334 0l358.4-204.8c25.6-17.066667 42.666667-42.666667 42.666666-72.533333V307.2c0-34.133333-17.066667-59.733333-42.666666-76.8z m-42.666667 486.4L512 925.866667l-358.4-204.8V307.2L512 98.133333l358.4 204.8v413.866667z" fill="#707070" p-id="7646"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#5b6472" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="侧边栏">
<rect x="3" y="4" width="18" height="16" rx="2.5"/>
<line x1="9.5" y1="4" x2="9.5" y2="20"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#5b6472" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="任务面板">
<rect x="3" y="4" width="18" height="16" rx="2.5"/>
<line x1="14.5" y1="4" x2="14.5" y2="20"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788836090677" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5475" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M852.9 147.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V156.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V156.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="5476"></path><path d="M290.5 214h-60v60h60v-60zM393.5 214h-60v60h60v-60zM806 214H591v60h215v-60zM852.9 417.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V426.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V426.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="5477"></path><path d="M290.5 484h-60v60h60v-60zM393.5 484h-60v60h60v-60zM806 484H591v60h215v-60zM852.9 687.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V696.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V696.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="5478"></path><path d="M290.5 754h-60v60h60v-60zM393.5 754h-60v60h60v-60zM806 754H591v60h215v-60z" p-id="5479"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788836438084" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5804" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M864 691.2h-704c-51.2 0-96 44.8-96 96v64c0 51.2 44.8 96 96 96h704c51.2 0 96-44.8 96-96v-64c0-51.2-44.8-96-96-96z m32 160c0 19.2-12.8 32-32 32h-704c-19.2 0-32-12.8-32-32v-64c0-19.2 12.8-32 32-32h704c19.2 0 32 12.8 32 32v64z" fill="" p-id="5805"></path><path d="M812.8 819.2m-44.8 0a44.8 44.8 0 1 0 89.6 0 44.8 44.8 0 1 0-89.6 0Z" fill="" p-id="5806"></path><path d="M864 384h-704C108.8 384 64 428.8 64 480v64c0 51.2 44.8 96 96 96h704c51.2 0 96-44.8 96-96v-64c0-51.2-44.8-96-96-96z m32 160c0 19.2-12.8 32-32 32h-704c-19.2 0-32-12.8-32-32v-64c0-19.2 12.8-32 32-32h704c19.2 0 32 12.8 32 32v64z" fill="" p-id="5807"></path><path d="M812.8 512m-44.8 0a44.8 44.8 0 1 0 89.6 0 44.8 44.8 0 1 0-89.6 0Z" fill="" p-id="5808"></path><path d="M864 76.8h-704c-51.2 0-96 38.4-96 96v64c0 51.2 44.8 96 96 96h704c51.2 0 96-44.8 96-96v-64c0-57.6-44.8-96-96-96z m32 160c0 19.2-12.8 32-32 32h-704c-19.2 0-32-12.8-32-32v-64c0-19.2 12.8-32 32-32h704c19.2 0 32 12.8 32 32v64z" fill="" p-id="5809"></path><path d="M812.8 204.8m-44.8 0a44.8 44.8 0 1 0 89.6 0 44.8 44.8 0 1 0-89.6 0Z" fill="" p-id="5810"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788836426771" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5639" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M960 42.666667H64c-12.8 0-21.333333 8.533333-21.333333 21.333333v896c0 12.8 8.533333 21.333333 21.333333 21.333333h896c12.8 0 21.333333-8.533333 21.333333-21.333333V64c0-12.8-8.533333-21.333333-21.333333-21.333333z m-21.333333 896H85.333333V682.666667h853.333334v256z m0-298.666667H85.333333V384h853.333334v256z m0-298.666667H85.333333V85.333333h853.333334v256zM298.666667 768h-42.666667v85.333333h42.666667v-85.333333z m-85.333334 0H170.666667v85.333333h42.666666v-85.333333z m661.333334 21.333333h-170.666667v42.666667h170.666667v-42.666667zM298.666667 469.333333h-42.666667v85.333334h42.666667v-85.333334z m-85.333334 0H170.666667v85.333334h42.666666v-85.333334z m661.333334 21.333334h-170.666667v42.666666h170.666667v-42.666666zM298.666667 170.666667h-42.666667v85.333333h42.666667V170.666667z m-85.333334 0H170.666667v85.333333h42.666666V170.666667z m661.333334 21.333333h-170.666667v42.666667h170.666667V192z" fill="#4D4D4D" p-id="5640"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M827.107556 170.652444c-2.673778 0-5.603556 0.426667-8.732445 1.479112l-629.617778 209.863111c-15.616 5.219556-17.848889 18.531556-18.062222 24.021333-0.227556 5.475556 0.867556 18.915556 16.014222 25.415111l242.986667 104.135111a111.317333 111.317333 0 0 1 58.737778 58.737778l104.120889 242.972444c6.485333 15.160889 20.010667 16.341333 25.429333 16.028445 5.475556-0.241778 18.787556-2.460444 23.992889-18.062222l209.877333-629.632c4.664889-14.008889-2.915556-23.608889-6.4-27.079111-2.688-2.716444-9.088-7.879111-18.346666-7.879112m-210.389334 768a110.876444 110.876444 0 0 1-102.599111-67.768888l-104.149333-242.986667a26.24 26.24 0 0 0-13.880889-13.880889L153.088 509.866667a111.004444 111.004444 0 0 1-67.655111-107.420445 110.961778 110.961778 0 0 1 76.344889-101.404444L791.395556 91.164444A111.160889 111.160889 0 0 1 905.813333 118.186667a111.146667 111.146667 0 0 1 27.022223 114.389333L722.915556 862.193778A110.961778 110.961778 0 0 1 616.718222 938.666667" fill="#333333"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M827.107556 170.652444c-2.673778 0-5.603556 0.426667-8.732445 1.479112l-629.617778 209.863111c-15.616 5.219556-17.848889 18.531556-18.062222 24.021333-0.227556 5.475556 0.867556 18.915556 16.014222 25.415111l242.986667 104.135111a111.317333 111.317333 0 0 1 58.737778 58.737778l104.120889 242.972444c6.485333 15.160889 20.010667 16.341333 25.429333 16.028445 5.475556-0.241778 18.787556-2.460444 23.992889-18.062222l209.877333-629.632c4.664889-14.008889-2.915556-23.608889-6.4-27.079111-2.688-2.716444-9.088-7.879111-18.346666-7.879112m-210.389334 768a110.876444 110.876444 0 0 1-102.599111-67.768888l-104.149333-242.986667a26.24 26.24 0 0 0-13.880889-13.880889L153.088 509.866667a111.004444 111.004444 0 0 1-67.655111-107.420445 110.961778 110.961778 0 0 1 76.344889-101.404444L791.395556 91.164444A111.160889 111.160889 0 0 1 905.813333 118.186667a111.146667 111.146667 0 0 1 27.022223 114.389333L722.915556 862.193778A110.961778 110.961778 0 0 1 616.718222 938.666667" fill="#ffffff"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M170.666667 256.256c0-140.586667 160.512-220.885333 273.024-136.533333l341.205333 255.658666c91.093333 68.266667 91.093333 204.885333 0 273.152L443.733333 904.234667C331.178667 988.586667 170.666667 908.288 170.666667 767.701333V256.213333z m221.866666-68.266667C336.213333 145.834667 256 185.984 256 256.256v511.402667c0 70.314667 80.213333 110.464 136.533333 68.266666l341.205334-255.658666a85.333333 85.333333 0 0 0 0-136.576L392.490667 187.989333z" fill="#333333"></path>
</svg>

After

Width:  |  Height:  |  Size: 582 B

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M170.666667 256.256c0-140.586667 160.512-220.885333 273.024-136.533333l341.205333 255.658666c91.093333 68.266667 91.093333 204.885333 0 273.152L443.733333 904.234667C331.178667 988.586667 170.666667 908.288 170.666667 767.701333V256.213333z m221.866666-68.266667C336.213333 145.834667 256 185.984 256 256.256v511.402667c0 70.314667 80.213333 110.464 136.533333 68.266666l341.205334-255.658666a85.333333 85.333333 0 0 0 0-136.576L392.490667 187.989333z" fill="#ffffff"></path>
</svg>

After

Width:  |  Height:  |  Size: 582 B

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M550.4 277.333333h260.266667c56.554667 0 102.4 45.845333 102.4 102.4v375.466667c0 56.554667-45.845333 102.4-102.4 102.4H213.333333c-56.554667 0-102.4-45.845333-102.4-102.4V234.666667c0-37.704533 30.562133-68.266667 68.266667-68.266667h264.789333a68.266667 68.266667 0 0 1 58.1376 32.4864L550.4 277.333333z m259.84 528.695467c28.279467 0 51.2-22.9248 51.2-51.2v-375.466667c0-28.2752-22.920533-51.2-51.2-51.2H162.56v426.666667c0 28.2752 22.9248 51.2 51.2 51.2h596.48zM465.7152 226.197333A17.066667 17.066667 0 0 0 450.897067 217.6H179.2a17.066667 17.066667 0 0 0-17.066667 17.066667v41.634133L494.933333 277.333333l-29.218133-51.136z" fill="#555555"></path>
</svg>

After

Width:  |  Height:  |  Size: 762 B

View File
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""用「备份 ZIP 里的真实旧库」验证:迁移新列后旧数据必须 100% 原样保留(只读对比)。
用法: python tests/check_db_migration.py <backup.zip>
"""
import os
import sys
import json
import shutil
import sqlite3
import tempfile
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
ZIP = sys.argv[1] if len(sys.argv) > 1 else r"D:/haocode_backup_20260916_1102.zip"
OUT = os.path.join(tempfile.gettempdir(), "haocode_from_backup.db")
if os.path.exists(OUT):
os.remove(OUT)
with zipfile.ZipFile(ZIP) as z:
name = [n for n in z.namelist()
if os.path.basename(n) == "chat_history.db"][0]
with z.open(name) as src, open(OUT, "wb") as dst:
shutil.copyfileobj(src, dst)
print("从备份取出:", name, os.path.getsize(OUT), "bytes")
def fp(path):
c = sqlite3.connect(path)
c.row_factory = sqlite3.Row
cols = [r[1] for r in c.execute("PRAGMA table_info(messages)")]
d = {
"messages_rows": c.execute("SELECT COUNT(*) FROM messages").fetchone()[0],
"sessions_rows": c.execute("SELECT COUNT(*) FROM sessions").fetchone()[0],
"msg_sum": list(c.execute(
"SELECT COUNT(*), SUM(LENGTH(COALESCE(content,''))), "
"SUM(LENGTH(COALESCE(timeline,''))) FROM messages").fetchone()[:]),
"parents": [tuple(r) for r in c.execute(
"SELECT id, parent_id FROM messages ORDER BY id")],
"leaves": [tuple(r) for r in c.execute(
"SELECT id, current_leaf_msg_id FROM sessions ORDER BY id")],
"branches": sorted((r["parent_id"], r["n"]) for r in c.execute(
"SELECT parent_id, COUNT(*) n FROM messages "
"WHERE parent_id IS NOT NULL GROUP BY parent_id HAVING n>1")),
"roles": sorted((r["role"], r["n"]) for r in c.execute(
"SELECT role, COUNT(*) n FROM messages GROUP BY role")),
"has_new_cols": all(x in cols for x in ("stop_reason", "error_message")),
}
c.close()
return d
before = fp(OUT)
print("备份库(迁移前): 消息=%d 会话=%d 分支点=%d 含新列=%s" % (
before["messages_rows"], before["sessions_rows"],
len(before["branches"]), before["has_new_cols"]))
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = OUT
d = _dbm.DBManager(db_path=OUT) # ← 触发迁移
after = fp(OUT)
print("迁移后 : 消息=%d 会话=%d 分支点=%d 含新列=%s" % (
after["messages_rows"], after["sessions_rows"],
len(after["branches"]), after["has_new_cols"]))
print()
keys = ("messages_rows", "sessions_rows", "msg_sum", "parents",
"leaves", "branches", "roles")
for k in keys:
print((" OK " if before[k] == after[k] else " DIFF ") + "%-14s" % k)
allok = all(before[k] == after[k] for k in keys)
print()
print("每个会话叶子:", [(r[0][:16], (r[1] or "")[:16]) for r in after["leaves"][:4]], "...")
print(">>> 结论:", "✅ 旧库数据 100% 原样保留(只多两个空列)"
if allok else "❌ 有改动!")
os.remove(OUT)
sys.exit(0 if allok else 1)
+48
View File
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""代理侧调试注入 CLI(与运行中的 app 通过文件通信)
用法:
python tests/debug_inject.py "备注内容" # 注入 [AGENT] 日志行
python tests/debug_inject.py --show # 打开独立调试窗口
python tests/debug_inject.py --hide # 关闭独立调试窗口
python tests/debug_inject.py --read [N] # 读取会话日志最后 N 行(默认 50)
"""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from core import debug_log as dl
def main():
args = sys.argv[1:]
if not args:
print(__doc__)
return 1
if args[0] == "--show":
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write("show")
print(f"已请求打开调试窗口 -> {dl.DEBUG_CMD_PATH}")
return 0
if args[0] == "--hide":
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write("hide")
print(f"已请求关闭调试窗口 -> {dl.DEBUG_CMD_PATH}")
return 0
if args[0] == "--read":
n = int(args[1]) if len(args) > 1 else 50
if not os.path.exists(dl.DEBUG_LOG_PATH):
print("(会话日志尚不存在)")
return 0
with open(dl.DEBUG_LOG_PATH, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
print(f"===== {os.path.basename(dl.DEBUG_LOG_PATH)} 最后 {min(n, len(lines))} 行 =====")
for l in lines[-n:]:
print(l)
return 0
# 普通文本 → 注入 [AGENT]
dl.debug_log(args[0], "AGENT")
print(f"已注入 [AGENT]: {args[0]}")
return 0
if __name__ == "__main__":
sys.exit(main())
+85
View File
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
r"""真实 API 测试:opencode-go / deepseek-v4-flash + 完整 agent 循环(工具执行)
运行: C:\Users\14890\miniconda3\envs\haocode\python.exe -u tests/diag_live_agent.py
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
from core.agent import (Agent, AgentConfig, ModelConfig, RetryConfig) # noqa: E402
from core.agent.recovery import AgentRunner # noqa: E402
from core.agent.stream_fn import openai_stream # noqa: E402
from core.agent.tools import default_tools # noqa: E402
MODEL_NAME = "deepseek-v4-flash"
cfg = json.load(open(os.path.join(ROOT, "data", "config.json"), encoding="utf-8"))
prov = cfg["providers"]["opencode-go"]
model = ModelConfig(
provider="opencode-go", name=MODEL_NAME,
context_window=int(prov.get("model_contexts", {}).get(MODEL_NAME, 1000000)),
max_tokens=4096, temperature=0.3,
api_key=prov["api_key"], base_url=prov["base_url"],
)
with open(os.path.join(ROOT, "SYSTEM_PROMPT.md"), encoding="utf-8") as f:
system_prompt = f.read()
agent_cfg = AgentConfig(
model=model,
tools=default_tools(),
system_prompt=system_prompt,
tool_context={"cwd": ROOT},
retry=RetryConfig(max_attempts=2, base_delay_ms=1000),
)
agent = Agent(agent_cfg)
agent.set_stream_fn(openai_stream)
def show(e):
if e.type == "message_update" and e.assistant_message_event is not None:
ev = e.assistant_message_event
if ev.type == "text_delta":
print(" [正文] " + ev.text, end="", flush=True)
elif ev.type == "thinking_delta":
print(" [思考] " + ev.text, end="", flush=True)
elif ev.type == "toolcall_delta":
print(f" [tc] {ev.tool_call_field}={ev.tool_call_delta!r}",
end="", flush=True)
elif e.type == "tool_execution_start" and e.tool_call is not None:
print(f"\n [工具开始] {e.tool_call.name} 参数={e.tool_call.arguments}")
elif e.type == "tool_execution_update" and e.arg:
print(" [工具输出] " + str(e.arg), end="", flush=True)
elif e.type == "tool_execution_end" and e.tool_call is not None:
c = e.result.content if e.result else ""
if not isinstance(c, str):
c = "".join(x.get("text", "") for x in c if isinstance(x, dict))
print(f"\n [工具结束] ok={not e.is_error} 结果={c[:200]!r}")
elif e.type == "agent_end":
print(f"\n [agent_end] stop_reason={e.stop_reason} "
f"error={getattr(e.error, 'message', None)}")
agent.subscribe(show)
runner = AgentRunner(agent)
print("=" * 60)
print(f"实时测试: {model.base_url} / {MODEL_NAME}")
print(f"system prompt: {len(system_prompt)} 字符, tools: {len(default_tools())}")
print("=" * 60)
runner.run("请用 bash 工具执行命令: echo hello-from-haocode && ls,然后告诉我输出结果。")
print("\n===== 最终消息链 =====")
for m in agent.state.messages:
tc = f" tool_calls={[t.name for t in m.tool_calls]}" if m.tool_calls else ""
print(f"- {m.role}: {(m.content or '')[:100]!r}{tc}")
ok = any(m.role == "toolResult" for m in agent.state.messages)
print("\n===== 结论:", "✅ 真实 tool_call 被发出并执行" if ok
else "❌ 没有工具执行(可能供应商不支持 tools API,检查是否走了文字兜底)", "=====")
sys.exit(0 if ok else 1)
+186
View File
@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
"""E2E onscreen:真实可见窗口 + 真实 API + 流式过程中多点采样 DOM。
复现用户环境(非 offscreen,rAF 行为与真实窗口一致)。
运行: python tests/diag_live_onscreen.py (会在桌面弹出窗口)
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.pop("QT_QPA_PLATFORM", None) # onscreen
os.environ.setdefault("HAOCODE_RENDER", "software")
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from core.agent.types import AssistantMessageEvent # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
window = None
results = []
SNAP_BUF_JS = (
"(function() {"
" var w = document.getElementById('msg-e2e-text');"
" if (!w) return 'no-wrapper';"
" var segs = w.querySelectorAll('.md-segment');"
" var out = [];"
" for (var i = 0; i < segs.length; i++) {"
" out.push('buf:' + ((segs[i].__buf || '').length)"
" + '/dom:' + ((segs[i].textContent || '').length));"
" }"
" return out.join(' ') || 'no-segs';"
"})()"
)
SNAP_JS = (
"(function() {"
" var w = document.getElementById('msg-e2e-text');"
" if (!w) return 'no-wrapper';"
" var segs = w.querySelectorAll('.md-segment');"
" var n = 0;"
" for (var i = 0; i < segs.length; i++)"
" if ((segs[i].textContent || '').trim()) n++;"
" return 'segs=' + segs.length + ' nonempty=' + n +"
" ' chip=' + !!w.querySelector('.tool-chip') +"
" ' streaming=' + w.classList.contains('streaming');"
"})()"
)
FINAL_JS = """
(function() {
try {
var w = document.getElementById('msg-e2e-text');
if (!w) return JSON.stringify({error: 'no wrapper'});
var tl = w.querySelector('.reply-content');
var blocks = Array.prototype.map.call(tl.children,
function(el) { return el.className.split(' ')[0]; });
var segs = w.querySelectorAll('.md-segment');
var segTexts = Array.prototype.map.call(segs,
function(x) { return (x.textContent || '').slice(0, 80); });
var chip = w.querySelector('.tool-chip');
return JSON.stringify({blocks: blocks, segTexts: segTexts,
chip: !!chip, streaming: w.classList.contains('streaming')});
} catch (e) { return JSON.stringify({error: String(e)}); }
})()
"""
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
def step1():
global window
window = MainWindow()
window.show()
QTimer.singleShot(3500, step2)
def step2():
from core.llm_engine import AgentWorker
import core.llm_engine as le
db = window.db
sess = db.create_session("E2E onscreen 测试")
sid = sess["id"]
mid = "msg-e2e-text"
user_row = db.add_message(sid, "user", "测试问题", None)
window.current_session_id = sid
window.chat_bridge.create_message(mid, "assistant", "", "E2E")
window._active_streams[sid] = {
"msg_id": mid, "content": "", "reasoning": "",
"timeline": [], "tl_kind": None,
"parent_id": user_row["id"], "branch_info": None, "worker": None,
"previous_leaf_id": user_row["id"],
}
real_stream = le.openai_stream
def logging_stream(context, model, signal, max_tokens, tools=None):
for kind, payload in real_stream(context, model, signal,
max_tokens, tools):
t = getattr(payload, "type", kind)
if t in ("text_delta", "thinking_delta", "toolcall_delta"):
txt = str(getattr(payload, "text", ""))[:25]
print(f" [stream] {t} {txt!r}", flush=True)
else:
print(f" [stream] {kind}/{t}", flush=True)
yield kind, payload
le.openai_stream = logging_stream
worker = AgentWorker(provider_name=window.current_provider,
model_name=window.current_model,
openai_messages=[{"role": "user",
"content": "运行命令 echo hello-from-onscreen "
"并告诉我输出内容"}],
enable_tools=True)
worker.reasoning_received.connect(lambda t: window.on_reasoning_received(sid, t))
worker.chunk_received.connect(lambda t: window.on_chunk_received(sid, t))
worker.tool_execution_started.connect(
lambda cid, name, args: window._on_tool_started(sid, cid, name, args))
worker.tool_execution_updated.connect(
lambda cid, text: window._on_tool_updated(sid, cid, text))
worker.tool_execution_finished.connect(
lambda cid, name, ok, text: window._on_tool_finished(sid, cid, name, ok, text))
worker.error_occurred.connect(
lambda err: (print(" [worker error]", err, flush=True),
window.on_error(sid, err)))
worker.finished.connect(lambda: window.on_reply_finished(sid))
window._active_streams[sid]["worker"] = worker
worker.start()
print("worker started(窗口已可见,观察屏幕)...", flush=True)
def snapshot(tag):
window.browser.page().runJavaScript(
SNAP_JS,
lambda v, tag=tag: print(f" [snapshot {tag}] {v}", flush=True))
window.browser.page().runJavaScript(
SNAP_BUF_JS,
lambda v, tag=tag: print(f" [snapshot {tag} BUF] {v}", flush=True))
QTimer.singleShot(8000, lambda: snapshot("t+8s"))
QTimer.singleShot(15000, lambda: snapshot("t+15s"))
QTimer.singleShot(25000, lambda: snapshot("t+25s"))
QTimer.singleShot(45000, step3)
def step3():
def got(res):
d = json.loads(str(res))
if "error" in d:
check("E2E DOM", False, d["error"])
finish()
return
print(f" blocks = {d['blocks']}")
print(f" segTexts = {d['segTexts']}")
print(f" chip={d['chip']} streaming={d['streaming']}")
check("live 正文段非空", any(t.strip() for t in d["segTexts"]),
str(d["segTexts"]))
check("最终回答含命令输出",
any("hello-from-onscreen" in t for t in d["segTexts"]),
str(d["segTexts"]))
check("工具 chip 存在", d["chip"])
check("streaming 已收尾", not d["streaming"])
finish()
window.browser.page().runJavaScript(FINAL_JS, got)
def finish():
try:
window.db.delete_session(window.current_session_id)
except Exception:
pass
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
QTimer.singleShot(500, step1)
app.exec()
+187
View File
@@ -0,0 +1,187 @@
# -*- coding: utf-8 -*-
"""E2E live 测试:真实 AgentWorker + 假流式 + 真实 MainWindow 信号链路。
验证 live 时正文 md-segment 是否有内容(用户报告的 bug)。
运行: QT_QPA_PLATFORM=offscreen python tests/diag_live_text.py
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from core.agent.types import (AssistantMessageEvent, AgentMessage, ToolCall) # noqa: E402
from core.agent import stream_fn as sf # noqa: E402
from core.agent.tools import default_tools # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
window = None
results = []
CALL_STATE = {"n": 0}
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
# ---- 假 stream_fn:第 1 轮 思考+文本+bash 工具调用;第 2 轮 最终回答 ----
def fake_stream(context, model, signal, max_tokens, tools=None):
CALL_STATE["n"] += 1
n = CALL_STATE["n"]
if n == 1:
yield ("event", AssistantMessageEvent(type="thinking_delta", text="我先看看"))
yield ("event", AssistantMessageEvent(type="thinking_delta", text="目录。"))
yield ("event", AssistantMessageEvent(type="text_delta", text="我来执行命令"))
yield ("event", AssistantMessageEvent(type="toolcall_delta",
tool_call_index=0, tool_call_field="id", tool_call_delta="call-e2e-1"))
yield ("event", AssistantMessageEvent(type="toolcall_delta",
tool_call_index=0, tool_call_field="name", tool_call_delta="bash"))
yield ("event", AssistantMessageEvent(type="toolcall_delta",
tool_call_index=0, tool_call_field="arguments",
tool_call_delta='{"command": "echo e2e-ok"}'))
time.sleep(0.3)
yield ("final", AgentMessage(
role="assistant", stop_reason="stop",
tool_calls=[ToolCall(id="call-e2e-1", name="bash",
raw_arguments='{"command": "echo e2e-ok"}')]))
else:
for tok in ["最终", "回答", ":任务", "完成。"]:
yield ("event", AssistantMessageEvent(type="text_delta", text=tok))
time.sleep(0.05)
yield ("final", AgentMessage(role="assistant", stop_reason="stop"))
def step1():
global window
window = MainWindow()
QTimer.singleShot(3500, step2)
def step2():
from core.llm_engine import AgentWorker
db = window.db
sess = db.create_session("E2E 正文测试")
sid = sess["id"]
mid = "msg-e2e-text"
user_row = db.add_message(sid, "user", "测试问题", None)
window.current_session_id = sid
window.chat_bridge.create_message(mid, "assistant", "", "E2E")
window._active_streams[sid] = {
"msg_id": mid, "content": "", "reasoning": "",
"timeline": [], "tl_kind": None,
"parent_id": user_row["id"], "branch_info": None, "worker": None,
"previous_leaf_id": user_row["id"],
}
# 真实 API + 流事件日志(定位 text_delta 是否到达)
import core.llm_engine as le
real_stream = le.openai_stream
def logging_stream(context, model, signal, max_tokens, tools=None):
n_ev = 0
for kind, payload in real_stream(context, model, signal,
max_tokens, tools):
t = getattr(payload, "type", kind)
txt = str(getattr(payload, "text", ""))[:30]
if t not in ("text_delta", "thinking_delta", "toolcall_delta"):
print(f" [stream] {kind}/{t}", flush=True)
else:
n_ev += 1
if n_ev <= 6 or n_ev % 20 == 0:
print(f" [stream] {t} {txt!r}", flush=True)
yield kind, payload
le.openai_stream = logging_stream
worker = AgentWorker(provider_name=window.current_provider,
model_name=window.current_model,
openai_messages=[{"role": "user",
"content": "运行命令 echo hello-from-diag 并告诉我输出"}],
enable_tools=True)
# 与 send_message 相同的信号连接
worker.reasoning_received.connect(lambda t: window.on_reasoning_received(sid, t))
worker.chunk_received.connect(lambda t: window.on_chunk_received(sid, t))
worker.tool_execution_started.connect(
lambda cid, name, args: window._on_tool_started(sid, cid, name, args))
worker.tool_execution_updated.connect(
lambda cid, text: window._on_tool_updated(sid, cid, text))
worker.tool_execution_finished.connect(
lambda cid, name, ok, text: window._on_tool_finished(sid, cid, name, ok, text))
worker.error_occurred.connect(lambda err: (print(' [worker error]', err), window.on_error(sid, err)))
worker.finished.connect(lambda: window.on_reply_finished(sid))
window._active_streams[sid]["worker"] = worker
worker.start()
print("worker started, 等待流结束...")
QTimer.singleShot(40000, step3)
def step3():
# live DOM 检查(不重载 DB
js = """
(function() {
try {
var w = document.getElementById('msg-e2e-text');
if (!w) return JSON.stringify({error: 'no wrapper'});
var tl = w.querySelector('.reply-content');
var blocks = Array.prototype.map.call(tl.children,
function(el) { return el.className.split(' ')[0]; });
var segs = w.querySelectorAll('.md-segment');
var segTexts = Array.prototype.map.call(segs,
function(x) { return x.textContent; });
var thinks = w.querySelectorAll('.think-content');
var thinkTexts = Array.prototype.map.call(thinks,
function(x) { return x.textContent; });
var chip = w.querySelector('.tool-chip');
return JSON.stringify({blocks: blocks, segTexts: segTexts,
thinkTexts: thinkTexts, chip: !!chip,
streaming: w.classList.contains('streaming')});
} catch (e) { return JSON.stringify({error: String(e)}); }
})()
"""
def got(res):
d = json.loads(str(res))
if "error" in d:
check("E2E DOM", False, d["error"])
finish()
return
print(f" blocks = {d['blocks']}")
print(f" segTexts = {d['segTexts']}")
print(f" thinkTexts = {d['thinkTexts']}")
print(f" chip = {d['chip']} streaming={d['streaming']}")
check("live 正文段有内容(至少一段非空)",
any((t or "").strip() for t in d["segTexts"]),
str(d["segTexts"]))
check("live 最终回答含命令输出",
any("hello-from-diag" in (t or "") for t in d["segTexts"]),
str(d["segTexts"]))
check("live 思考段有内容(或模型未思考)",
True, str(d["thinkTexts"])[:80])
check("live 工具 chip 存在", d["chip"])
check("streaming 已收尾", not d["streaming"])
# 时间线入库检查
st = window._active_streams.get(sid := window.current_session_id)
tl = window.db.get_session(window.current_session_id) if False else None
finish()
window.browser.page().runJavaScript(js, got)
def finish():
try:
window.db.delete_session(window.current_session_id)
except Exception:
pass
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
QTimer.singleShot(500, step1)
app.exec()
+75
View File
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""问题 3 取证:右侧栏 bash 层「参数/输出」框的滚动条 QSS 现状(离屏截图 + 几何/样式打印)"""
import os
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q3_{os.getpid()}.db")
_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q3_{os.getpid()}.json")
open(_cfg, "w", encoding="utf-8").write('{"providers": {}}')
os.environ["HAOCODE_CONFIG_FILE"] = _cfg
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
w = MainWindow()
w.resize(1400, 950)
w.show()
for _ in range(40):
app.processEvents()
QTest.qWait(20)
p = w.bash_panel
p.expand_btn.click()
for _ in range(30):
app.processEvents()
QTest.qWait(20)
# 造一个「已完成 + 长输出」的层(长输出会把垂直/水平滚动条都逼出来)
long_cmd = "python train.py --epochs 300 --batch-size 64 --lr 0.0001 --data /data/detr/annotations.json"
long_out = "\n".join(
f"[{i:04d}] epoch loss=0.{i % 9}{i % 7} lr=0.0001 mAP=0.{40 + i % 20} "
f"very_long_tail_to_force_horizontal_scrollbar_{i}"
for i in range(120))
p.on_started("q3_lay", "bash", {"command": long_cmd})
p.on_finished("q3_lay", "bash", True, long_out)
for _ in range(20):
app.processEvents()
QTest.qWait(20)
lay = p._layers["q3_lay"]
lay.toggle() # 展开层 → 显示「参数」「输出」两块
for _ in range(20):
app.processEvents()
QTest.qWait(20)
for nm, box in (("参数 arg_box", lay.arg_box), ("输出 out_box", lay.out_box)):
vb = box.verticalScrollBar()
hb = box.horizontalScrollBar()
print(f"--- {nm}")
print(f" objectName={box.objectName()} 尺寸={box.width()}x{box.height()}")
print(f" 垂直滚动条: 可见={vb.isVisible()} 宽={vb.width()} 需要={vb.maximum() > 0}")
print(f" 水平滚动条: 可见={hb.isVisible()} 高={hb.height()} 需要={hb.maximum() > 0}")
print(f" box 自身 stylesheet = {box.styleSheet()!r}")
print(f" box 背景角色 = {box.palette().base().color().name()}")
img = lay.grab().toImage()
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q3_layer.png")
img.save(out)
print("\n层截图 ->", out, img.width(), "x", img.height())
# 再抓取面板整块(看滚动条在面板里的观感)
img2 = p.grab().toImage()
out2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q3_panel.png")
img2.save(out2)
print("面板截图 ->", out2, img2.width(), "x", img2.height())
os.remove(_cfg)
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""问题 5 取证:会话改名覆盖层(RenameOverlay)的"聚光灯"遮罩现状"""
import os
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q5_{os.getpid()}.db")
_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q5_{os.getpid()}.json")
open(_cfg, "w", encoding="utf-8").write('{"providers": {}}')
os.environ["HAOCODE_CONFIG_FILE"] = _cfg
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from ui.views.main_window import MainWindow, RenameOverlay, SessionContextPopup # noqa: E402
app = QApplication(sys.argv)
w = MainWindow()
w.resize(1300, 900)
w.show()
for _ in range(40):
app.processEvents()
QTest.qWait(20)
sid = w.db.create_session("聚光灯测试会话")["id"]
w.load_messages_to_web(sid)
for _ in range(20):
app.processEvents()
QTest.qWait(20)
print("=== SessionContextPopup 是否为独立顶层窗口 ===")
print(" windowFlags 含 Qt.Popup ?", bool(SessionContextPopup(sid, "", w).windowFlags() & 0x80000000))
pop = SessionContextPopup(sid, "聚光灯测试会话", w)
print(" isWindow() =", pop.isWindow(), " windowType =", pop.windowFlags())
print()
print("=== RenameOverlay 现状 ===")
w._rename_session(sid)
for _ in range(25):
app.processEvents()
QTest.qWait(20)
ovs = [c for c in w.children() if isinstance(c, RenameOverlay)]
if not ovs:
print(" ✗ 没找到 RenameOverlay")
else:
ov = ovs[0]
print(" 父对象 =", ov.parent().__class__.__name__)
print(" isWindow() =", ov.isWindow(), " (False = 主窗口的子控件,只覆盖客户区)")
print(" 几何 =", ov.geometry())
print(" 主窗口 rect =", w.rect(), " 主窗口 frameGeometry =", w.frameGeometry())
print(" WA_TranslucentBackground =", ov.testAttribute(
__import__('PyQt6.QtCore', fromlist=['Qt']).Qt.WidgetAttribute.WA_TranslucentBackground))
print(" graphicsEffect =", type(ov.graphicsEffect()).__name__ if ov.graphicsEffect() else None)
print(" 遮罩色 =", ov._overlay_color.getRgb())
print(" form 几何 =", ov.form.geometry(), " form 是否在遮罩内 =",
ov.rect().contains(ov.form.geometry()))
# 看遮罩是否盖住了标题栏 / 是否超出窗口
print(" 遮罩 top-left(全局) =", ov.mapToGlobal(ov.rect().topLeft()),
" bottom-right(全局) =", ov.mapToGlobal(ov.rect().bottomRight()))
print(" 主窗口(全局) =", w.mapToGlobal(w.rect().topLeft()), w.mapToGlobal(w.rect().bottomRight()))
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q5_overlay.png")
ov.grab().save(out)
print(" 覆盖层截图 ->", out, ov.width(), "x", ov.height())
out2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q5_window.png")
w.grab().save(out2)
print(" 主窗口截图 ->", out2, w.width(), "x", w.height())
os.remove(_cfg)
+90
View File
@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""问题 1 取证:长会话渲染的真实 DOM 规模与耗时(离屏,临时库)"""
import os
import sys
import time
import json
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q1_{os.getpid()}.db")
_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q1_{os.getpid()}.json")
open(_cfg, "w", encoding="utf-8").write('{"providers": {}}')
os.environ["HAOCODE_CONFIG_FILE"] = _cfg
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
N = int(sys.argv[1]) if len(sys.argv) > 1 else 200
app = QApplication(sys.argv)
w = MainWindow()
w.resize(1200, 850)
w.show()
for _ in range(30):
app.processEvents()
QTest.qWait(20)
sid = w.db.create_session(f"长会话渲染取证 {N}")["id"]
parent = None
# 造 N 轮(每轮 user + assistantassistant 带代码块 + 工具时间线,贴近真实负载)
for i in range(N // 2):
parent = w.db.add_message(session_id=sid, role="user",
content=f"{i} 个问题:帮我看看这段代码\n```python\nprint({i})\n```",
parent_id=parent)["id"]
tl = json.dumps([
{"t": "think", "text": "分析中…" * 20},
{"t": "text", "text": f"### 回答 {i}\n\n要点如下:\n\n- 第一条\n- 第二条\n\n```python\nfor j in range(10):\n print(j)\n```\n"},
{"t": "tool", "id": f"c{i}", "name": "bash", "args": '{"command":"echo hi"}',
"ok": True, "result": "hi\n" * 30},
], ensure_ascii=False)
parent = w.db.add_message(session_id=sid, role="assistant",
content=f"回答 {i}:见代码块与工具结果。",
parent_id=parent, timeline=tl)["id"]
chain = w.db.get_message_chain(sid)
print(f"库内消息条数 = {len(chain)}")
t0 = time.time()
w.load_messages_to_web(sid)
for _ in range(80):
app.processEvents()
QTest.qWait(25)
dt = time.time() - t0
holder = {}
done = []
def got(res):
holder["dom"] = res
done.append(1)
w.browser.page().runJavaScript(
"JSON.stringify({wrappers: document.querySelectorAll('.message-wrapper').length,"
" nodes: document.getElementsByTagName('*').length,"
" height: document.scrollingElement.scrollHeight,"
" codeBlocks: document.querySelectorAll('.code-block-wrapper').length,"
" katex: document.querySelectorAll('.katex').length})", got)
for _ in range(60):
app.processEvents()
QTest.qWait(25)
if done:
break
print(f"渲染耗时 ≈ {dt:.2f}s")
print("DOM 统计 =", holder.get("dom"))
if holder.get("dom"):
d = json.loads(holder["dom"])
print(f" → 消息节点 {d['wrappers']} 个 / 全 DOM 节点 {d['nodes']} 个 / "
f"页面总高 {d['height']}px / 代码块 {d['codeBlocks']}")
print(f" → 平均每条消息 {d['nodes'] // max(1, d['wrappers'])} 个 DOM 节点")
os.remove(_cfg)
+118
View File
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""公式渲染验收消息注入:新建「公式渲染验收」会话,写入用户原始样本 + 正/负例
运行: PYTHONIOENCODING=utf-8 python tests/inject_math_demo.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from core.db_manager import DBManager # noqa: E402
USER_MSG = """帮我看看这个推导,公式渲染对不对?
上面是推导结果:
[
P_4=\\operatorname{BRF}(M_4,M_5)
]
[
P_3=\\operatorname{BRF}(S_3,P_4)
]
其中:
[
\\operatorname{BRF}(L,H)
L+
\\Gamma(L,\\operatorname{Up}(H))
\\odot
\\Phi(\\operatorname{Up}(H))
]
"""
ASSISTANT_MSG = r"""## 公式渲染验收
### 1) 你原始样本(单括号块,应渲染为独立居中公式)
上面是推导结果:
[
P_4=\operatorname{BRF}(M_4,M_5)
]
[
P_3=\operatorname{BRF}(S_3,P_4)
]
其中:
[
\operatorname{BRF}(L,H)
L+
\Gamma(L,\operatorname{Up}(H))
\odot
\Phi(\operatorname{Up}(H))
]
### 2) 标准定界符
行内混合:能量公式 $E=mc^2$ 出现在句子中间;再来一个 $x_i^2 + y_j^2 = z_{ij}^2$。
行内括号形式:\(\alpha + \beta = \gamma\)
双美元块:
$$
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
$$
方括号块:
\[
\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6}
\]
带矩阵与希腊字母:
[
\begin{pmatrix} a & b \\ c & d \end{pmatrix}
\begin{pmatrix} x \\ y \end{pmatrix}
=
\begin{pmatrix} ax+by \\ cx+dy \end{pmatrix}
]
### 3) 反例(不应渲染成公式)
- 编号引用:见[1]和[2]的说明。
- 链接:[KaTeX 官网](https://katex.org)
- 列表内容:[a, b] 只是一个数组。
- 货币:价格 $1,000 and $2,000 之间。
- 代码块:
```python
price = "$5"
pattern = r"$x + y$"
arr[0] = 1
```
- 行内代码:使用 `$z$` 表示变量。
### 4) 复杂嵌套(流式增量渲染路径同样适用)
$$
f(x) = \sum_{k=0}^{n} \binom{n}{k} x^k (1-x)^{n-k}
$$
"""
def main():
db = DBManager()
sess = db.create_session("公式渲染验收")
sid = sess["id"]
parent = sess.get("current_leaf_msg_id")
m1 = db.add_message(sid, "user", USER_MSG, parent_id=parent)
db.add_message(sid, "assistant", ASSISTANT_MSG, parent_id=m1["id"])
db.mark_session_has_messages(sid)
print(f"已注入会话: {sid}")
print(f"标题: 公式渲染验收")
print(f"用户消息 {len(USER_MSG)}c / 助手消息 {len(ASSISTANT_MSG)}c")
return 0
if __name__ == "__main__":
sys.exit(main())
+33
View File
@@ -0,0 +1,33 @@
"""离线测试 harness(无 pytest 依赖):
运行: conda run -n haocode python tests/run_tests.py
"""
import importlib.util
import os
import sys
import traceback
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
spec = importlib.util.spec_from_file_location(
"test_agent_core", os.path.join(os.path.dirname(__file__), "test_agent_core.py"))
mod = importlib.util.module_from_spec(spec)
# 替换 pytest 依赖后加载
import types
_py_stub = types.ModuleType("pytest")
sys.modules["pytest"] = _py_stub
spec.loader.exec_module(mod)
tests = [(n, f) for n, f in sorted(vars(mod).items())
if n.startswith("test_") and callable(f)]
passed = failed = 0
for name, fn in tests:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print(f"\n===== {passed} passed, {failed} failed / {len(tests)} =====")
sys.exit(1 if failed else 0)
+614
View File
@@ -0,0 +1,614 @@
# -*- coding: utf-8 -*-
"""右侧任务面板 · offscreen 端到端
链路:顶部按钮(原「导出」位置)→ BashPanel 展开/收起 → tool_execution_* 事件
→ 层(运行中/已完成)→ 单击展开(参数 + 输出)→ 会话切换刷新
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_bash_panel.py
"""
import os
import sys
import json
import tempfile
import shutil
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# 铁律:测试不得污染真实 data/chat_history.db
import core.db_manager as _dbm # noqa: E402
_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_bashpanel_{os.getpid()}.db")
if os.path.exists(_DB_TMP):
os.remove(_DB_TMP)
_dbm._DEFAULT_DB = _DB_TMP
# 铁律:宽度记录会写 config.json → 测试指向临时配置,绝不碰真实 data/config.json
_CFG_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_cfg_{os.getpid()}.json")
with open(_CFG_TMP, "w", encoding="utf-8") as _f:
_f.write('{"providers": {}, "mode_switch": true}')
os.environ["HAOCODE_CONFIG_FILE"] = _CFG_TMP
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from PyQt6 import QtCore, QtWidgets # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
from ui.views.bash_panel import BashLayer, LIVE_BUF_CAP, LAYER_LIMIT # noqa: E402
app = QApplication(sys.argv)
ok = True
def check(name, cond, extra=""):
global ok
print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" {extra}"), flush=True)
if not cond:
ok = False
def settle(ms=400):
for _ in range(int(ms / 20) + 1):
app.processEvents()
QTest.qWait(20)
window = MainWindow()
window.show()
panel = window.bash_panel
def px(widget, x, y):
"""取控件自身坐标系下 (x,y) 的渲染像素"""
img = widget.grab().toImage()
c = img.pixelColor(x, y)
return (c.red(), c.green(), c.blue())
# ======================================================================
# 1) 外壳:默认收起 52px、顶部按钮存在且尺寸与左侧收起按钮一致
# ======================================================================
check("P1.1 面板已创建并挂进主布局", panel is not None and panel.parent() is not None)
check("P1.2 默认收起 52px", panel.width() == 52 and panel.collapsed is True,
f"w={panel.width()} collapsed={panel.collapsed}")
check("P1.3 「导出」按钮已移除", not hasattr(window, "btn_export"))
# 🆕 开关只在右栏内部(与左侧栏一致),主界面顶部【不得】有任何面板按钮
check("P1.4 主界面顶部【没有】面板开关按钮", not hasattr(window, "btn_panel"))
def visible_toggles():
"""全窗口可见的面板开关按钮(tooltip 含“任务面板”)"""
return [b for b in window.findChildren(QtWidgets.QPushButton)
if "任务面板" in (b.toolTip() or "") and b.isVisible()]
check("P1.5 收起态:可见开关恒为 1 个", len(visible_toggles()) == 1, str(len(visible_toggles())))
check("P1.6 收起态开关 = 面板内的 expand_btn(居中)",
visible_toggles()[0] is panel.expand_btn)
check("P1.7 expand_btn 34×34、icon 18×18(与左侧栏收起页按钮同规格)",
panel.expand_btn.width() == 34 and panel.expand_btn.height() == 34
and panel.expand_btn.iconSize() == QtCore.QSize(18, 18)
and panel.expand_btn.width() == window.collapse_expand_btn.width(),
f"{panel.expand_btn.width()}x{panel.expand_btn.height()} vs 左 {window.collapse_expand_btn.width()}")
check("P1.8 expand_btn 与左侧按钮同 objectName(继承同一套 QSS",
panel.expand_btn.objectName() == window.collapse_expand_btn.objectName() == "collapse_btn")
_x = panel.expand_btn.mapTo(panel, QtCore.QPoint(0, 0)).x()
check("P1.9 expand_btn 水平居中于 52px 栏内",
abs((_x + 34 / 2) - panel.width() / 2) <= 1.5, f"center={_x + 17} panel_half={panel.width()/2}")
# 展开(用栏内按钮,不用主界面按钮)
panel.expand_btn.click()
settle(500)
check("P1.10 点击收起页按钮 → 展开到 260px", panel.width() == 260 and panel.collapsed is False,
f"w={panel.width()} collapsed={panel.collapsed}")
check("P1.11 展开态:可见开关仍恒为 1 个(不叠加)",
len(visible_toggles()) == 1, str(len(visible_toggles())))
check("P1.12 展开态开关 = 面板标题行右上角的 fold_btn",
visible_toggles()[0] is panel.fold_btn)
check("P1.13 fold_btn 28×28、icon 16×16(与左侧栏收起按钮同规格)",
panel.fold_btn.width() == 28 and panel.fold_btn.height() == 28
and panel.fold_btn.iconSize() == QtCore.QSize(16, 16)
and panel.fold_btn.width() == window.collapse_btn.width(),
f"{panel.fold_btn.width()}x{panel.fold_btn.height()} vs 左 {window.collapse_btn.width()}")
check("P1.14 fold_btn 与左侧按钮同 objectName(同一套 QSS",
panel.fold_btn.objectName() == window.collapse_btn.objectName() == "collapse_btn")
_fx = panel.fold_btn.mapTo(panel, QtCore.QPoint(0, 0)).x()
check("P1.15 fold_btn 位于标题行右端(右侧留白 ≤14px)",
panel.width() - (_fx + 28) <= 14, f"right_gap={panel.width() - (_fx + 28)}")
check("P1.16 展开后两栏同时可见(不互斥)",
(not panel.sec_running.isHidden()) and (not panel.sec_done.isHidden()),
f"run_hidden={panel.sec_running.isHidden()} done_hidden={panel.sec_done.isHidden()}")
check("P1.17 两栏为上下排列(分隔线可拖动)",
panel.splitter.orientation() == QtCore.Qt.Orientation.Vertical)
check("P1.18 默认两栏提示语",
panel.sec_running.hint.text() == "暂无正在运行的 bash"
and panel.sec_done.hint.text() == "本会话还没有已完成的 bash",
f"{panel.sec_running.hint.text()!r} / {panel.sec_done.hint.text()!r}")
# 🐛 已修:QWidget 子类样式表背景 + 底色/分隔线继承左侧
check("P1.19 样式表背景已启用(WA_StyledBackground",
panel.testAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground))
check("P1.20 面板底色 = 左侧栏底色 (#f7f8fa)",
px(panel, 30, 400) == (0xf7, 0xf8, 0xfa), str(px(panel, 30, 400)))
check("P1.21 面板左侧分隔线 = 左侧栏分隔线 (#ececec)",
px(panel, 0, 400) == (0xec, 0xec, 0xec), str(px(panel, 0, 400)))
check("P1.22 左侧栏对照:底色+分隔线",
px(window.sidebar, 30, 400) == (0xf7, 0xf8, 0xfa)
and px(window.sidebar, 259, 400) == (0xec, 0xec, 0xec),
f"{px(window.sidebar, 30, 400)} {px(window.sidebar, 259, 400)}")
check("P1.23 标题行与左侧栏标题行垂直对齐(y 相等、高 36)",
panel.header.mapTo(window, QtCore.QPoint(0, 0)).y()
== window.sidebar_header.mapTo(window, QtCore.QPoint(0, 0)).y()
and panel.header.height() == window.sidebar_header.height() == 36,
f"panel_y={panel.header.mapTo(window, QtCore.QPoint(0, 0)).y()} "
f"left_y={window.sidebar_header.mapTo(window, QtCore.QPoint(0, 0)).y()}")
# ======================================================================
# 2) 事件驱动:运行中 → 实时输出 → 读秒 → 完成归位
# ======================================================================
sid = window.current_session_id
if not sid:
window.on_new_chat_clicked()
sid = window.current_session_id
window._on_tool_started(sid, "c1", "bash", json.dumps({"command": "echo hi"}, ensure_ascii=False))
check("P2.1 bash 事件 → 运行中栏出现 1 层", panel.layer_ids("running") == ["c1"],
str(panel.layer_ids("running")))
check("P2.2 已完成栏仍为空", panel.layer_ids("done") == [], str(panel.layer_ids("done")))
lay = panel._layers["c1"]
check("P2.3 层头显示 bash + 运行中标签",
lay.name.text() == "bash" and lay.tag.text() == "运行中", lay.tag.text())
check("P2.4 层头显示命令预览", "echo hi" in lay.cmd.text(), lay.cmd.text())
window._on_tool_updated(sid, "c1", "hi\n")
window._on_tool_updated(sid, "c1", "second\n")
check("P2.5 实时输出被累积", lay._live == "hi\nsecond\n", repr(lay._live))
window._on_tool_timed(sid, "c1", 3, 120)
check("P2.6 读秒显示 3/120s", lay.meta.text() == "3/120s", lay.meta.text())
# 非 bash 工具不登记
window._on_tool_started(sid, "c9", "read", json.dumps({"path": "x"}))
check("P2.7 非 bash 工具不登记", "c9" not in panel._layers)
# 完成 → 归位到已完成栏
final_text = "$ echo hi\nhi\n[exit 0] (0.1s)"
window._on_tool_finished(sid, "c1", "bash", True, final_text)
check("P2.8 完成后从运行中移除", panel.layer_ids("running") == [], str(panel.layer_ids("running")))
check("P2.9 完成后进入已完成栏", panel.layer_ids("done") == ["c1"], str(panel.layer_ids("done")))
check("P2.10 已完成标签 + 耗时(从 (0.1s) 解析)",
lay.tag.text() == "已完成" and lay.meta.text() == "0.1s", f"{lay.tag.text()} {lay.meta.text()}")
check("P2.11 已完成层输出 = 进入上下文的原文", lay._final == final_text)
# ======================================================================
# 3) 单击展开 → 参数 + 输出
# ======================================================================
check("P3.1 初始为收起态", lay.expanded is False and lay.body.isHidden())
QTest.mouseClick(lay.head, QtCore.Qt.MouseButton.LeftButton,
QtCore.Qt.KeyboardModifier.NoModifier,
QtCore.QPoint(10, lay.head.height() // 2))
settle(120)
check("P3.2 单击(非双击)即展开", lay.expanded is True and not lay.body.isHidden())
check("P3.3 参数区显示完整命令", lay.arg_box.toPlainText() == "echo hi",
repr(lay.arg_box.toPlainText()))
check("P3.4 输出区标题标明「进入上下文」", lay.lbl_out.text() == "输出(进入上下文)",
lay.lbl_out.text())
check("P3.5 输出区显示上下文原文", lay.out_box.toPlainText() == final_text)
QTest.mouseClick(lay.head, QtCore.Qt.MouseButton.LeftButton,
QtCore.Qt.KeyboardModifier.NoModifier,
QtCore.QPoint(10, lay.head.height() // 2))
settle(120)
check("P3.6 再点一次收起", lay.expanded is False and lay.body.isHidden())
# 运行中的层展开 → 实时输出
window._on_tool_started(sid, "c2", "bash", json.dumps({"command": "ping -t x"}))
window._on_tool_updated(sid, "c2", "line-A\n")
lay2 = panel._layers["c2"]
lay2.toggle()
check("P3.7 运行中层输出标题 = 输出(实时)", lay2.lbl_out.text() == "输出(实时)",
lay2.lbl_out.text())
check("P3.8 运行中层实时内容可见", "line-A" in lay2.out_box.toPlainText())
window._on_tool_updated(sid, "c2", "line-B\n")
check("P3.9 展开状态下新输出即时可见(无需重新展开)",
"line-B" in lay2.out_box.toPlainText(), repr(lay2.out_box.toPlainText()))
window._on_tool_finished(sid, "c2", "bash", False, "$ ping -t x\nline-A\n[exit 1] (0.5s)")
check("P3.10 失败层标签为「失败」", lay2.tag.text() == "失败", lay2.tag.text())
# 实时缓冲上限
big = BashLayer("cbuf", "x")
for _ in range(260):
big.append_live("y" * 1000)
check("P3.11 实时缓冲截到 200KB 内", len(big._live) <= LIVE_BUF_CAP, str(len(big._live)))
check("P3.12 截断被标注", big._live_truncated is True)
# ======================================================================
# 4) 压缩切点 → 已出上下文标注
# ======================================================================
sid2 = window.db.create_session("面板压缩测试")["id"]
leaf = window.db.get_session_leaf(sid2)
tl1 = json.dumps([{"t": "tool", "id": "old1", "name": "bash",
"args": json.dumps({"command": "old-cmd"}),
"ok": True, "result": "$ old-cmd\nold\n[exit 0] (0.1s)"}],
ensure_ascii=False)
m_sys = leaf
m_u1 = window.db.add_message(sid2, "user", "q1", m_sys)["id"]
m_a1 = window.db.add_message(sid2, "assistant", "a1", m_u1, timeline=tl1)["id"]
m_u2 = window.db.add_message(sid2, "user", "q2", m_a1)["id"]
tl2 = json.dumps([{"t": "tool", "id": "new1", "name": "bash",
"args": json.dumps({"command": "new-cmd"}),
"ok": True, "result": "$ new-cmd\nnew\n[exit 0] (0.1s)"}],
ensure_ascii=False)
m_a2 = window.db.add_message(sid2, "assistant", "a2", m_u2, timeline=tl2)["id"]
window.db.insert_compaction_mark(sid2, "【摘要】前文略", cut_before_id=m_a1,
first_retained_id=m_u2)
window.load_messages_to_web(sid2)
settle(300)
check("P4.1 切换会话后面板跟随刷新", set(panel.layer_ids()) == {"old1", "new1"},
str(panel.layer_ids()))
check("P4.2 压缩切点之前的层标注「已出上下文」",
panel._layers["old1"].tag.text() == "已出上下文", panel._layers["old1"].tag.text())
check("P4.3 切点之后的层仍为「已完成」",
panel._layers["new1"].tag.text() == "已完成", panel._layers["new1"].tag.text())
check("P4.4 已完成层不截断输出(原文保留)",
panel._layers["old1"]._final.endswith("[exit 0] (0.1s)"))
# ======================================================================
# 5) 层数上限
# ======================================================================
sid3 = window.db.create_session("面板层数上限")["id"]
leaf3 = window.db.get_session_leaf(sid3)
entries = [{"t": "tool", "id": f"many{i}", "name": "bash",
"args": json.dumps({"command": f"cmd{i}"}),
"ok": True, "result": f"$ cmd{i}\nok\n[exit 0] (0.1s)"} for i in range(35)]
window.db.add_message(sid3, "assistant", "批量", leaf3,
timeline=json.dumps(entries, ensure_ascii=False))
window.load_messages_to_web(sid3)
settle(300)
check("P5.1 只渲染最近 30 层", panel.sec_done.count.text() == str(LAYER_LIMIT),
panel.sec_done.count.text())
check("P5.2 有「仅显示最近 N 层」提示", "仅显示最近 30 层" in panel.sec_done.hint.text(),
panel.sec_done.hint.text())
check("P5.3 面板内部仍保留全部 35 层(只是不渲染)",
len(panel.layer_ids("done")) == 35, str(len(panel.layer_ids("done"))))
# ======================================================================
# 6) 不自动收缩(resizeEvent 不影响右侧面板)+ 手动切换
# ======================================================================
was = panel.collapsed
window.resize(920, 700)
settle(200)
check("P6.1 窗口变小不触发面板自动收缩", panel.collapsed == was,
f"before={was} after={panel.collapsed}")
window.resize(1400, 800)
settle(200)
check("P6.2 窗口变大也不触发面板自动展开/收缩", panel.collapsed == was)
panel.fold_btn.click()
settle(500)
check("P6.3 只有点栏内按钮才收起", panel.width() == 52 and panel.collapsed is True)
check("P6.4 收起后可见开关切回 expand_btn(恒为 1 个)",
len(visible_toggles()) == 1 and visible_toggles()[0] is panel.expand_btn)
panel.expand_btn.click()
settle(500)
check("P6.5 收起页按钮再点一次即可展开", panel.width() == 260 and panel.collapsed is False)
check("P6.6 全窗口仍只有 1 个可见面板开关(且主界面顶部没有)",
len(visible_toggles()) == 1 and not hasattr(window, "btn_panel"))
# ======================================================================
# 7) 会话切回空会话 → 面板清空
# ======================================================================
sid4 = window.db.create_session("面板空会话")["id"]
window.load_messages_to_web(sid4)
settle(250)
check("P7.1 切到空会话后面板清空", panel.layer_ids() == [], str(panel.layer_ids()))
# ======================================================================
# 8) 🆕 左边缘拖拽调宽 → 记录 → 下次展开自动恢复(含最小宽度)
# ======================================================================
from ui.views.bash_panel import (PANEL_W_DEFAULT, PANEL_W_MIN, PANEL_W_MAX, # noqa: E402
BashPanel, load_panel_width)
if panel.collapsed:
panel.expand_btn.click()
settle(400)
check("P8.1 拖拽手柄存在、宽 4px、贴在最左",
panel._handle.width() == 4 and panel._handle.x() == 0,
f"w={panel._handle.width()} x={panel._handle.x()}")
check("P8.2 手柄覆盖面板全高", panel._handle.height() == panel.height(),
f"{panel._handle.height()} vs {panel.height()}")
check("P8.3 手柄光标 = 水平拖拽",
panel._handle.cursor().shape() == QtCore.Qt.CursorShape.SizeHorCursor)
check("P8.4 最小宽度已设定(%d px" % PANEL_W_MIN,
PANEL_W_MIN == 200 and panel.W_MIN == PANEL_W_MIN)
def drag_to(width):
"""模拟真实拖拽:按下(记右边界)→ 鼠标移到目标位置 → 松手"""
right = panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x()
panel._drag_begin(right)
panel._drag_to_global_x(right - width)
panel._commit_drag_width()
settle(120)
# 8.1 正常拖宽 → 立即生效 + 落盘
cfg_before = json.load(open(_CFG_TMP, encoding="utf-8"))
drag_to(340)
check("P8.5 拖到 340px 立即生效", panel.width() == 340 and panel.collapsed is False,
f"w={panel.width()}")
cfg_after = json.load(open(_CFG_TMP, encoding="utf-8"))
check("P8.6 已落盘 config.json['bash_panel_width'] = 340",
cfg_after.get("bash_panel_width") == 340, str(cfg_after.get("bash_panel_width")))
check("P8.7 落盘不破坏其它键(providers/mode_switch 保留)",
"providers" in cfg_after and cfg_after.get("mode_switch") is True)
# 8.2 收起再展开 → 回到记录值(不是默认 260)
panel.fold_btn.click()
settle(450)
check("P8.8 收起仍为 52px", panel.width() == 52 and panel.collapsed is True, f"w={panel.width()}")
check("P8.9 收起条居中按钮不被 4px 手柄遮住", panel.expand_btn.x() >= panel._handle.width(),
f"btn_x={panel.expand_btn.x()}")
panel.expand_btn.click()
settle(450)
check("P8.10 再展开 = 记录的 340px(不是默认 %d" % PANEL_W_DEFAULT,
panel.width() == 340 and panel.W_EXPAND == 340, f"w={panel.width()} W_EXPAND={panel.W_EXPAND}")
# 8.3 最小宽度夹紧
win_half = int(window.width() * 0.5)
drag_to(120)
check("P8.11 拖到 120px → 被夹到最小 200px", panel.width() == PANEL_W_MIN, f"w={panel.width()}")
check("P8.12 夹紧后仍是展开态(不会误判为收起)",
panel.collapsed is False and panel.width() > panel.W_COLLAPSE)
# 8.4 上限夹紧
drag_to(5000)
exp_hi = min(PANEL_W_MAX, max(PANEL_W_MIN, win_half))
check("P8.13 拖到 5000px → 被夹到上限(≤%d" % exp_hi, panel.width() == exp_hi,
f"w={panel.width()} exp={exp_hi}")
# 8.5 从收起态直接拖开 → 自动进展开态
drag_to(PANEL_W_MIN)
panel.fold_btn.click()
settle(450)
check("P8.14 已收起 52px", panel.width() == 52)
panel._drag_begin(panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x())
panel._drag_to_global_x(panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x() + 100)
check("P8.15 从收起态向外拖 → 立即进入展开态(不为 0/负数)",
panel.collapsed is False and panel.width() >= PANEL_W_MIN, f"w={panel.width()}")
panel._commit_drag_width()
settle(120)
# 8.6 持久化:新实例直接读出记录值
drag_to(300)
check("P8.16 记录 300px 后,新实例读出同一值",
load_panel_width() == 300, str(load_panel_width()))
_p2 = BashPanel()
check("P8.17 新面板 W_EXPAND 即上次记录值(重启后自动恢复)",
_p2.W_EXPAND == 300, str(_p2.W_EXPAND))
_p2.deleteLater()
# 8.7 只拖不松手不写盘
with open(_CFG_TMP, "w", encoding="utf-8") as _f:
json.dump({"providers": {}, "mode_switch": True}, _f)
right = panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x()
panel._drag_begin(right)
panel._drag_to_global_x(right - 420)
check("P8.18 拖动过程中不写盘(只在松手时记录)",
json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width") is None)
panel._commit_drag_width()
check("P8.19 松手才落盘",
json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width") == panel.width(),
str(json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width")))
# ======================================================================
# 9) 🆕 两栏折叠/展开:只收「下面填充的 bash 层」,头部行高恒定(抽屉动画)
# ======================================================================
if panel.collapsed:
panel.expand_btn.click()
settle(400)
# 造数据:运行中 1 层 + 已完成 2 层
panel.on_started("p9_run", "bash", {"command": "python long_task.py"})
panel.on_timed("p9_run", 3, 120)
panel.on_started("p9_d1", "bash", {"command": "echo a"})
panel.on_finished("p9_d1", "bash", True, "a")
panel.on_started("p9_d2", "bash", {"command": "echo b"})
panel.on_finished("p9_d2", "bash", True, "b")
settle(300)
sr, sd = panel.sec_running, panel.sec_done
check("P9.0 两栏都有内容(运行中1 / 已完成2)",
len(panel.layer_ids("running")) == 1 and len(panel.layer_ids("done")) == 2,
f"{panel.layer_ids('running')} / {panel.layer_ids('done')}")
head_h = sr.head_height()
total0 = sum(panel.splitter.sizes())
check("P9.1 头部行高 = 24px 量级(收起/展开恒定的那一行)",
16 <= head_h <= 40, f"head_h={head_h}")
check("P9.2 最小高已被重写为「只剩头部行」(bug 根因解除)",
sr.minimumSizeHint().height() == head_h and sd.minimumSizeHint().height() == sd.head_height(),
f"{sr.minimumSizeHint().height()} vs head {head_h}")
h_run_before = sr.height()
h_done_before = sd.height()
# ---- 收起「运行中」-----
sr.head.clicked.emit()
settle(120) # 只等一半 → 应处于动画中间态
mid_run = sr.height()
settle(400)
check("P9.3 收起「运行中」→ 该栏只剩头部高(空白归零)",
abs(sr.height() - head_h) <= 2, f"h={sr.height()} head={head_h}")
check("P9.4 splitter sizes[0] == 头部高",
abs(panel.splitter.sizes()[0] - head_h) <= 2, str(panel.splitter.sizes()))
check("P9.5 「已完成」栏吃掉让出的空间",
sd.height() > h_done_before + 50, f"{h_done_before} -> {sd.height()}")
check("P9.6 让出空间 = 两栏高度之和不变",
abs((sr.height() + sd.height()) - (h_run_before + h_done_before)) <= 2,
f"{h_run_before}+{h_done_before} vs {sr.height()}+{sd.height()}")
check("P9.7 动画是渐变(中间帧介于首末之间)",
head_h < mid_run < h_run_before, f"mid={mid_run} ({head_h}, {h_run_before})")
check("P9.8 收起后内容区不可见(scroll.isVisible() == False",
not sr.scroll.isVisible() and not sr.body.isVisible())
check("P9.9 收起后头部行仍可见且高度不变",
sr.head.isVisible() and abs(sr.head.height() - head_h) <= 1, f"head={sr.head.height()}")
check("P9.10 收起态:栏头箭头变 ▸", sr.chev.text() == "")
check("P9.11 收起态:中间拖动分隔条被禁用",
panel.splitter.handle(1).isEnabled() is False)
# ---- 再展开 ----
sr.head.clicked.emit()
settle(120)
mid2 = sr.height()
settle(400)
check("P9.12 再展开 → 恢复原高度(±3)",
abs(sr.height() - h_run_before) <= 3, f"{sr.height()} vs {h_run_before}")
check("P9.13 展开也是渐变", head_h <= mid2 <= h_run_before, f"mid={mid2}")
check("P9.14 展开后内容可见 / 箭头回 ▾ / 分隔条恢复可拖",
sr.scroll.isVisible() and sr.chev.text() == "" and panel.splitter.handle(1).isEnabled())
check("P9.15 折叠不影响层数据与实时缓冲",
len(panel.layer_ids("running")) == 1 and len(panel.layer_ids("done")) == 2
and panel._layers["p9_run"].ok is None)
# ---- 「已完成」栏单独收起同样生效 ----
sd.head.clicked.emit()
settle(500)
check("P9.16 收起「已完成」→ 该栏只剩头部高",
abs(sd.height() - sd.head_height()) <= 2, f"h={sd.height()} head={sd.head_height()}")
# 🆕 新口径:收起「已完成」不再让「运行中」吃掉空间,而是保持它的高度、
# 余量进底部留白(栏头紧跟上方内容,不被顶到底部)
check("P9.17 收起「已完成」→ 「运行中」高度保持不变(不再吃空间)",
abs(sr.height() - h_run_before) <= 3, f"{sr.height()} vs {h_run_before}")
sd.head.clicked.emit()
settle(500)
check("P9.18 展开恢复", abs(sd.height() - h_done_before) <= 3, f"{sd.height()} vs {h_done_before}")
# ---- 两栏都收起 → 两个头部相邻在顶部 ----
sr.head.clicked.emit(); settle(400)
sd.head.clicked.emit(); settle(400)
sizes2 = panel.splitter.sizes()
check("P9.19 两栏都收起 → sizes = [头部高, 头部高, 余量]",
abs(sizes2[0] - head_h) <= 2 and abs(sizes2[1] - head_h) <= 2
and abs(sum(sizes2) - (panel.splitter.height() - 2 * panel.splitter.handleWidth())) <= 3,
str(sizes2))
check("P9.20 两栏都收起时两个栏头相邻(间隔 = 分隔条宽)",
abs(sd.mapTo(panel, QtCore.QPoint(0, 0)).y()
- (sr.mapTo(panel, QtCore.QPoint(0, 0)).y() + sr.height())
- panel.splitter.handleWidth()) <= 2,
f"sec2_y={sd.mapTo(panel, QtCore.QPoint(0, 0)).y()} sec1_y={sr.mapTo(panel, QtCore.QPoint(0, 0)).y()}")
# ---- 连点 3 次 → 收敛到终态 ----
for _s in (sr, sd): # 先确保两栏都展开
if _s.folded:
_s.head.clicked.emit()
settle(500)
h_unfolded = sr.height()
sr.head.clicked.emit(); sr.head.clicked.emit(); sr.head.clicked.emit()
settle(700)
check("P9.21 未收起时连点 3 次 → 终态为收起,且只剩头部高",
sr.folded is True and abs(sr.height() - head_h) <= 2,
f"folded={sr.folded} h={sr.height()}")
sr.head.clicked.emit()
settle(700)
check("P9.22 连点后仍能正常展开并恢复原高度",
sr.folded is False and abs(sr.height() - h_unfolded) <= 3,
f"h={sr.height()} vs {h_unfolded}")
# ---- 面板尺寸变化后仍钉在头部高 ----
sr.head.clicked.emit()
settle(500)
sd_h_before = sd.head_height()
sizes_now = panel.splitter.sizes()
panel.splitter.setSizes([sizes_now[1], sizes_now[0]]) # 模拟被外部拉大/拉小
settle(120)
panel._repin_fold() # resizeEvent 里的重钉
settle(120)
check("P9.23 尺寸变化后收起栏仍只占头部高(_repin_fold 生效)",
abs(sr.height() - head_h) <= 2, f"h={sr.height()} head={head_h}")
sr.head.clicked.emit()
settle(500)
check("P9.24 收尾:两栏均展开,头部行高不变",
(not sr.folded) and (not sd.folded)
and sr.head_height() == head_h and sd.head_height() == sd_h_before,
f"{sr.head_height()}/{sd.head_height()} vs {head_h}/{sd_h_before}")
# ======================================================================
# 10) 🆕 两个实测 bug 的回归测试
# bug1: 收起后 head 被拉伸成巨条(实测 793px)
# bug2: 收起「已完成」→ 栏头被顶到面板最底部
# ======================================================================
def sec_y(s):
return s.mapTo(panel, QtCore.QPoint(0, 0)).y()
# ---- bug1: 头部行高【硬固定】(任意组合下都不被拉伸)----
combos = [(False, False), (True, False), (False, True), (True, True)]
bad = []
for want_r, want_d in combos:
for s, want in ((sr, want_r), (sd, want_d)):
if s.folded != want:
s.head.clicked.emit()
settle(450)
if sr.head.height() != sr.head_height() or sd.head.height() != sd.head_height():
bad.append((want_r, want_d, sr.head.height(), sd.head.height()))
check("P9.25 四种折叠组合下 head.height() 恒等于 head_height()(不再被拉伸)",
not bad, str(bad))
check("P9.26 两栏都收起时「已完成」head 高 == 24(修复前实测 793",
sr.folded and sd.folded and sd.head.height() == sd.head_height() == head_h,
f"folded=({sr.folded},{sd.folded}) head={sd.head.height()}")
_sp = panel.splitter.sizes()
check("P9.27 两栏都收起 → 余量进底部留白(spacer)",
_sp[2] > 100 and abs(sum(_sp) - (panel.splitter.height() - 2 * panel.splitter.handleWidth())) <= 3,
str(_sp))
check("P9.28 spacer 上方的分隔条不可拖且透明",
panel.splitter.handle(2).isEnabled() is False
and "transparent" in (panel.splitter.handle(2).styleSheet() or ""),
panel.splitter.handle(2).styleSheet())
# ---- bug2: 收起「已完成」→ 栏头紧跟「运行中」内容,而不是落到面板底部 ----
for s in (sr, sd):
if s.folded:
s.head.clicked.emit()
settle(450)
h_run_keep = sr.height()
sd.head.clicked.emit()
settle(500)
sp_y = sd.mapTo(panel, QtCore.QPoint(0, 0)).y()
check("P9.29 收起「已完成」→ 栏头紧跟在「运行中」下方(不是面板底部)",
abs(sp_y - (sec_y(sr) + sr.height() + panel.splitter.handleWidth())) <= 2,
f"done_y={sp_y} run_bottom={sec_y(sr) + sr.height()}")
check("P9.30 收起「已完成」→ 栏头远离面板底部(留白在它下面)",
sp_y < panel.height() - 100, f"done_y={sp_y} panel_h={panel.height()}")
check("P9.31 收起「已完成」→ 「运行中」高度保持记录值",
abs(sr.height() - h_run_keep) <= 3, f"{sr.height()} vs {h_run_keep}")
check("P9.32 收起「已完成」→ spacer 吸收余量",
panel.splitter.sizes()[2] > 100, str(panel.splitter.sizes()))
# ---- 展开恢复 ----
sd.head.clicked.emit()
settle(550)
check("P9.33 展开「已完成」→ 恢复记录高度",
abs(sd.height() - h_done_before) <= 4, f"{sd.height()} vs {h_done_before}")
check("P9.34 展开后 spacer 归零(已完成重新填满到底部)",
panel.splitter.sizes()[2] <= 2, str(panel.splitter.sizes()))
# ---- 不变量:任何折叠态下 sizes 之和 == 可用高度 ----
ok_sum = True
for want_r, want_d in combos:
for s, want in ((sr, want_r), (sd, want_d)):
if s.folded != want:
s.head.clicked.emit()
settle(450)
avail = panel.splitter.height() - 2 * panel.splitter.handleWidth()
if abs(sum(panel.splitter.sizes()) - avail) > 3:
ok_sum = False
check("P9.35 不变量:任意折叠态下 sizes 之和 == splitter 可用高度", ok_sum)
for s in (sr, sd):
if s.folded:
s.head.clicked.emit()
settle(450)
check("P9.36 收尾:两栏均展开", (not sr.folded) and (not sd.folded))
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
if os.path.exists(_DB_TMP):
os.remove(_DB_TMP)
if os.path.exists(_CFG_TMP):
os.remove(_CFG_TMP)
sys.exit(0 if ok else 1)
+156
View File
@@ -0,0 +1,156 @@
"""会话复制 · UI 入口端到端(offscreen
链路:弹窗「📋 复制」按钮 → action_triggered("copy") → on_session_action →
DBManager.copy_session → 侧边栏重建 + 自动切到副本
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_copy_session.py
"""
import os
import sys
import tempfile
import shutil
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# 铁律:测试不得污染真实 data/chat_history.db → 默认 DB 路径重定向到临时文件
import core.db_manager as _dbm # noqa: E402
_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_copy_ui_{os.getpid()}.db")
if os.path.exists(_DB_TMP):
os.remove(_DB_TMP)
_dbm._DEFAULT_DB = _DB_TMP
_FS_TMP = tempfile.mkdtemp(prefix="hocode_copy_ui_files_") # 临时附件根
from PyQt6.QtWidgets import QApplication, QPushButton # noqa: E402
from PyQt6 import QtCore # noqa: E402
from ui.views.main_window import MainWindow, SessionContextPopup # noqa: E402
app = QApplication(sys.argv)
ok = True
def check(name, cond, extra=""):
global ok
print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" {extra}"), flush=True)
if not cond:
ok = False
def chain_sig(sid):
return [(m["role"], m["content"]) for m in window.db.get_message_chain(sid)]
window = MainWindow()
window.db.files_root = _FS_TMP
# ======================================================================
# 1) 准备一个带内容的源会话(直接走 DB 层,不触发真实 API 请求)
# ======================================================================
window.on_new_chat_clicked()
src = window.current_session_id
for role, text in [("user", "帮我看看这个文件"), ("assistant", "好的,我先读一下")]:
window.db.add_message(src, role, text, window.db.get_session_leaf(src))
window.db.mark_session_has_messages(src)
window.rebuild_sidebar()
src_title = [s for s in window.db.get_all_sessions() if s["id"] == src][0]["title"]
check("源会话已就绪(2 条消息)", len(window.db.get_message_chain(src)) == 3,
str(len(window.db.get_message_chain(src))))
# 图片附件(验证 UI 链路里附件也被深拷贝)
os.makedirs(os.path.join(_FS_TMP, "data", "attachments"), exist_ok=True)
png = os.path.join(_FS_TMP, "data", "attachments", "ui_src.png")
with open(png, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\nUI-IMAGE-BYTES")
meta = ('{"user_text":"看图","attachments":[{"type":"image","size_kb":0.1,'
'"local_path":"data/attachments/ui_src.png"}]}')
window.db.add_message(src, "user", "以及这张图", window.db.get_session_leaf(src),
attachment_metadata=meta)
# ======================================================================
# 2) 弹窗里存在「复制」按钮,且点击后发出 ("copy", sid)
# ======================================================================
popup = SessionContextPopup(src, False, window)
btns = popup.findChildren(QPushButton)
texts = [b.text() for b in btns]
check("弹窗含「📋 复制」按钮", any("复制" in t for t in texts), str(texts))
check("按钮顺序 编辑/星标/复制/删除",
len(texts) == 4 and "编辑" in texts[0] and "星标" in texts[1]
and "复制" in texts[2] and "删除" in texts[3], str(texts))
got = []
popup.action_triggered.connect(lambda a, s: got.append((a, s)))
copy_btn = [b for b in btns if "复制" in b.text()][0]
copy_btn.click()
check("点击复制按钮发出 copy 动作", got == [("copy", src)], str(got))
# ======================================================================
# 3) on_session_action("copy") → 真实克隆 + 侧边栏刷新 + 切到副本
# ======================================================================
n_before = len(window.db.get_all_sessions())
window.on_session_action("copy", src)
sessions = window.db.get_all_sessions()
check("会话数 +1", len(sessions) == n_before + 1, f"{n_before}{len(sessions)}")
rows = [s for s in sessions if s["title"] == src_title + " (副本)"]
check("副本标题 = 源标题 + ' (副本)'", len(rows) == 1, str([s["title"] for s in sessions]))
copy_id = rows[0]["id"] if rows else None
if copy_id:
check("副本不是源(ID 不同)", copy_id != src)
check("消息链完全一致", chain_sig(src) == chain_sig(copy_id))
check("已自动切到副本", window.current_session_id == copy_id, str(window.current_session_id))
check("侧边栏含副本项",
any(window.history_list.item(i).data(QtCore.Qt.ItemDataRole.UserRole) == copy_id
for i in range(window.history_list.count())))
check("副本排在列表顶部(sort_order 最小)",
sessions[0]["id"] == copy_id, sessions[0]["title"])
check("副本不带星标", rows[0]["is_starred"] == 0)
check("副本 mode 跟随源(未锁定为 None)",
rows[0]["mode"] == window.db.get_session_mode(src))
# 附件深拷贝:新旧文件同时存在,且副本 metadata 指向新文件
import json as _json
c_rows = [m for m in window.db.get_message_chain(copy_id) if m.get("attachment_metadata")]
c_meta = _json.loads(c_rows[-1]["attachment_metadata"])
new_rel = c_meta["attachments"][0]["local_path"]
check("副本附件已改名", new_rel != "data/attachments/ui_src.png", new_rel)
check("新旧图片文件同时存在",
os.path.isfile(png) and os.path.isfile(os.path.join(_FS_TMP, new_rel)))
check("副本图片内容一致",
open(os.path.join(_FS_TMP, new_rel), "rb").read()
== open(png, "rb").read())
# 删副本后源完好(UI 链路下的删除隔离)
window.db.delete_session(copy_id)
check("删副本后源消息链不变", chain_sig(src) == [
("system", "你是一个优秀的助手!"), ("user", "帮我看看这个文件"),
("assistant", "好的,我先读一下"), ("user", "以及这张图")])
check("删副本后源图片仍在", os.path.isfile(png))
# ======================================================================
# 4) 生成中拒绝复制
# ======================================================================
n_before2 = len(window.db.get_all_sessions())
window._active_streams[src] = {"worker": None}
window.on_session_action("copy", src)
check("会话生成中 → 拒绝复制(无新会话)",
len(window.db.get_all_sessions()) == n_before2)
window._active_streams.pop(src, None)
# 5) 源不存在 → 静默失败,不崩
try:
window.on_session_action("copy", "sess_not_exist_zzz")
check("源不存在时不抛异常", True)
except Exception as e:
check("源不存在时不抛异常", False, str(e))
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
shutil.rmtree(_FS_TMP, ignore_errors=True)
if os.path.exists(_DB_TMP):
os.remove(_DB_TMP)
sys.exit(0 if ok else 1)
+280
View File
@@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
"""四项 UI 增强回归测试(真实 WebEngine 页面):
1. rAF 卡死模拟(AMD 核显/合成器不出帧)→ 40ms 兜底仍实时渲染流式正文
2. 深度思考默认收起 + .streaming-think 蓝色动画类;收尾后标签还原
3. bash chip 耗时徽章([exit 0] (1.2s)+ 超时徽章(命令超时(>120s))
4. 长结果:默认尾部 4000 字 + 展开按钮;点击展开全文/再点收起;
收起状态下 finish 后结果仍可见(修复「结束后展开为空」)
运行: python tests/smoke_live_guard.py
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
window = None
results = []
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
# Phase 0: rAF + 所有页面定时器全灭(极端节流)→
# 内容必须上屏:同步通道(token 内)+ Qt 看门狗(200msforceRenderNow
JS_PHASE_0A = r"""
(function() {
var out = {};
window.__realRAF = window.requestAnimationFrame;
window.__realCAF = window.cancelAnimationFrame;
window.__realTO = window.setTimeout;
window.__realCTO = window.clearTimeout;
window.__realSI = window.setInterval;
window.__realCSI = window.clearInterval;
window.__fakeRafId = 0;
window.__fakeTimerId = 1000;
window.requestAnimationFrame = function() { return ++window.__fakeRafId; };
window.cancelAnimationFrame = function(){};
window.setTimeout = function() { return ++window.__fakeTimerId; };
window.clearTimeout = function(){};
window.setInterval = function() { return ++window.__fakeTimerId; };
window.clearInterval = function(){};
var m0 = 'lg-sync-' + Date.now();
window.__lgM0 = m0;
createMessage(m0, 'assistant', '', 'LG');
appendReasoning(m0, 'sync think ');
appendToken(m0, 'sync text one ');
appendToken(m0, 'two');
var segs = document.querySelectorAll('#' + m0 + ' .md-segment');
var t = '';
for (var i = 0; i < segs.length; i++) t += segs[i].textContent || '';
out.textAtBurst = t;
return JSON.stringify(out);
})()
"""
JS_PHASE_0B = r"""
(function() {
var out = {};
var m0 = window.__lgM0;
var segs = document.querySelectorAll('#' + m0 + ' .md-segment');
var t = '';
for (var i = 0; i < segs.length; i++) t += segs[i].textContent || '';
var tcs = document.querySelectorAll('#' + m0 + ' .think-content');
var th = '';
for (var i = 0; i < tcs.length; i++) th += tcs[i].textContent || '';
out.text = t;
out.think = th;
// 恢复页面定时器
window.requestAnimationFrame = window.__realRAF;
window.cancelAnimationFrame = window.__realCAF;
window.setTimeout = window.__realTO;
window.clearTimeout = window.__realCTO;
window.setInterval = window.__realSI;
window.clearInterval = window.__realCSI;
finishMessage(m0);
return JSON.stringify(out);
})()
"""
# Phase A: 模拟 rAF 永不触发 + 流式 token;断言此刻 DOM 尚空(卡死生效)
JS_PHASE_A = r"""
(function() {
var out = {};
window.__realRAF = window.requestAnimationFrame;
window.__realCAF = window.cancelAnimationFrame;
// 模拟真实卡死:rAF 返回真实挂起 ID(truthy)但回调永不被调用
window.__fakeRafId = 0;
window.requestAnimationFrame = function(cb) { window.__rafCalls = (window.__rafCalls||0)+1; return ++window.__fakeRafId; };
window.cancelAnimationFrame = function(){};
var mid = 'lg-' + Date.now();
window.__lgMid = mid;
createMessage(mid, 'assistant', '', 'LG');
appendReasoning(mid, 'deep ');
appendReasoning(mid, 'thinking...');
appendToken(mid, 'live text one ');
appendToken(mid, 'two ');
appendToken(mid, 'three');
var segs = document.querySelectorAll('#' + mid + ' .md-segment');
var domLen = 0;
for (var i = 0; i < segs.length; i++)
domLen += (segs[i].textContent || '').length;
out.rafCalls = window.__rafCalls || 0;
out.domLenBeforeTimers = domLen;
// 思考块状态(默认收起 + 蓝色动画类)
var block = document.querySelector('#' + mid + ' .think-block');
out.thinkOpen = block ? block.open : null;
out.thinkStreaming = block ? block.classList.contains('streaming-think') : false;
out.thinkLabel = block ? (block.querySelector('.think-label')||{}).textContent : null;
return JSON.stringify(out);
})()
"""
# Phase Btimers 跑过之后): 兜底应已渲染正文 + 收尾 + 其余特性
JS_PHASE_B = r"""
(function() {
var out = {};
var mid = window.__lgMid;
var segs = document.querySelectorAll('#' + mid + ' .md-segment');
var domText = '';
for (var i = 0; i < segs.length; i++) domText += segs[i].textContent || '';
out.liveTextRendered = domText.indexOf('live text one two three') !== -1;
// 收尾:思考块标签还原 + 动画类移除
finishMessage(mid);
var block = document.querySelector('#' + mid + ' .think-block');
out.thinkLabelAfter = block ? (block.querySelector('.think-label')||{}).textContent : null;
out.thinkStreamingAfter = block ? block.classList.contains('streaming-think') : false;
// 恢复 rAF
window.requestAnimationFrame = window.__realRAF;
window.cancelAnimationFrame = window.__realCAF;
// ---- 耗时 / 超时徽章 ----
var mid2 = 'lg-timing-' + Date.now();
createMessage(mid2, 'assistant', '', 'LG');
toolExecutionStarted(mid2, 'call-ok', 'bash', JSON.stringify({command:'echo hi'}));
toolExecutionFinished(mid2, 'call-ok', 'bash', true, '$ echo hi\nhi\n[exit 0] (1.2s)');
toolExecutionStarted(mid2, 'call-tmo', 'bash', JSON.stringify({command:'sleep 999'}));
toolExecutionFinished(mid2, 'call-tmo', 'bash', false, '命令超时(>120s)已终止');
var w2 = document.getElementById(mid2);
var chipOk = w2.querySelector('[data-call-id="call-ok"]');
var chipTmo = w2.querySelector('[data-call-id="call-tmo"]');
out.durBadge = chipOk ? (chipOk.querySelector('.tool-chip-time')||{}).textContent : null;
out.tmoBadge = chipTmo ? (chipTmo.querySelector('.tool-chip-timeout')||{}).textContent : null;
// ---- 收起状态下 finish:结果必须已写入(展开可见) ----
var livePre = chipOk ? chipOk.querySelector('.tool-chip-live') : null;
out.resultWhileCollapsed = livePre ? (livePre.textContent || '').indexOf('hi') !== -1 : false;
// ---- 长结果:尾部预览 + 展开/收起 ----
var longRes = 'L'.repeat(1000) + ' MIDDLE-MARKER ' + 'R'.repeat(5000); // 6015 字,标记在头部(预览窗外)
toolExecutionStarted(mid2, 'call-long', 'read', JSON.stringify({path:'/big.txt'}));
toolExecutionFinished(mid2, 'call-long', 'read', true, longRes);
var chipLong = w2.querySelector('[data-call-id="call-long"]');
var pre = chipLong ? chipLong.querySelector('.tool-chip-live') : null;
var btn = chipLong ? chipLong.querySelector('.tool-expand-btn') : null;
out.longHasBtn = !!btn;
out.longPreviewLen = pre ? (pre.textContent || '').length : 0;
out.longPreviewIsTail = pre ? (pre.textContent || '').lastIndexOf('R') > (pre.textContent||'').lastIndexOf('MIDDLE-MARKER') : false;
out.longPreviewHasMarker = pre ? (pre.textContent || '').indexOf('MIDDLE-MARKER') !== -1 : true;
if (btn) {
btn.click();
out.expandedHasMarker = pre ? (pre.textContent || '').indexOf('MIDDLE-MARKER') !== -1 : false;
out.expandedLen = pre ? (pre.textContent || '').length : 0;
out.fullClass = pre ? pre.classList.contains('tool-chip-full') : false;
btn.click();
out.collapseBackLen = pre ? (pre.textContent || '').length : 0;
}
return JSON.stringify(out);
})()
"""
def step1():
global window
print("[step1] creating MainWindow...", flush=True)
window = MainWindow()
window.show()
print("[step1] shown, scheduling step2", flush=True)
QTimer.singleShot(3000, step2)
def step2():
print("[step2] phase 0: kill all page timers + burst tokens", flush=True)
# 注册为当前会话的活跃流 → Qt 看门狗(200ms)会对它 forceRenderNow
window.current_session_id = "fake-sync-sid"
window._active_streams["fake-sync-sid"] = {
"msg_id": None, "content": "pending", "reasoning": "",
"timeline": [], "tl_kind": None, "parent_id": None,
"branch_info": None, "worker": None, "previous_leaf_id": None,
}
window.browser.page().runJavaScript(JS_PHASE_0A, on_p0a)
def on_p0a(res):
a = json.loads(str(res))
print(f" phase0 burst 瞬间: text={a['textAtBurst']!r}")
# 把 JS 里生成的 mid 告诉看门狗(msg_id 需一致才会 forceRenderNow
# —— 简化:直接改 JS 端记录的 mid 对应的 buffer 即可,这里用 runJavaScript 对齐
window.browser.page().runJavaScript(
"window.__lgM0", lambda r: _align_and_wait(str(r)))
def _align_and_wait(mid):
mid = mid.strip().strip('"')
window._active_streams["fake-sync-sid"]["msg_id"] = mid
print("[phase0] 等待 Qt 看门狗渲染 (400ms) ...", flush=True)
QTimer.singleShot(400, on_p0b)
def on_p0b():
window.browser.page().runJavaScript(JS_PHASE_0B, on_p0)
def on_p0(res):
p = json.loads(str(res))
print(f" phase0 400ms 后: text={p['text']!r} think={p['think']!r}")
check("定时器全灭: burst 内思考同步上屏", "sync think" in p["think"], str(p))
check("定时器全灭: 正文经同步通道/看门狗上屏",
"sync text one two" in p["text"], str(p))
window._active_streams.pop("fake-sync-sid", None)
window.browser.page().runJavaScript(JS_PHASE_A, on_a)
def on_a(res):
print("[on_a] got result:", str(res)[:120], flush=True)
a = json.loads(str(res))
print(f" phaseA: rafCalls={a['rafCalls']} domLenBefore={a['domLenBeforeTimers']}")
# v7 起:首 token 插入前已同步渲染(:empty 布局失效修复的副作用——首字必现),
# 故此刻 DOM 可能已有首 token 内容;本断言只验证 rAF 被调用但从未触发。
check("rAF 被卡死(0 帧触发)", a["rafCalls"] > 0, str(a))
check("思考块默认收起", a["thinkOpen"] is False)
check("思考块带蓝色动画类 streaming-think", a["thinkStreaming"])
QTimer.singleShot(300, step3)
def step3():
window.browser.page().runJavaScript(JS_PHASE_B, on_b)
def on_b(res):
b = json.loads(str(res))
check("rAF 卡死时兜底仍实时渲染正文", b["liveTextRendered"], str(b))
check("收尾后思考标签还原为『已完成深度思考』",
b["thinkLabelAfter"] == "已完成深度思考", str(b["thinkLabelAfter"]))
check("收尾后移除 streaming-think 动画类", b["thinkStreamingAfter"] is False)
check("耗时徽章 ⏱ 1.2s", b["durBadge"] == "⏱ 1.2s", str(b["durBadge"]))
check("超时徽章 ⏱ 超时 120s", b["tmoBadge"] == "⏱ 超时 120s", str(b["tmoBadge"]))
check("收起状态下 finish 后结果仍可见(修复展开为空)", b["resultWhileCollapsed"])
check("长结果有展开按钮", b["longHasBtn"])
check("长结果默认只显示尾部 4002 字", b["longPreviewLen"] == 4002,
str(b["longPreviewLen"]))
check("尾部预览不含中段标记(标记在头部)", b["longPreviewIsTail"]
and b["longPreviewHasMarker"] is False)
check("点击展开全文(含中段标记 + full 类)",
b["expandedHasMarker"] and b["fullClass"]
and b["expandedLen"] > 5000, str(b))
check("再点收回到尾部预览", b["collapseBackLen"] == 4002,
str(b["collapseBackLen"]))
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
QTimer.singleShot(500, step1)
app.exec()
+42
View File
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""验证人肉 debug 埋点链路:
Python [正文]/[思考] 打印 + JS console 桥 → [JS] 打印 全部出现在控制台
"""
import os, sys
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt6.QtWidgets import QApplication
import PyQt6.QtWebEngineWidgets # noqa
from PyQt6.QtCore import QTimer
from ui.views.main_window import MainWindow
app = QApplication(sys.argv)
window = MainWindow()
window.resize(1280, 800)
window.show()
MID = "manual-debug-test"
def step1():
# 模拟一次真实的 send→token→finish 生命周期
window.chat_bridge.create_message(MID, "assistant", "", "Test")
window.chat_bridge.append_reasoning(MID, "这是思考内容A")
window.chat_bridge.append_reasoning(MID, "思考B")
window.chat_bridge.append_token(MID, "这是正文第一段。")
window.chat_bridge.append_token(MID, "正文第二段来了。")
window.chat_bridge.tool_execution_started(MID, "call-xyz", "bash", "echo hi")
window.chat_bridge.tool_execution_finished(MID, "call-xyz", "bash", True, "hi\n[exit 0] (0.1s)")
QTimer.singleShot(800, step2)
def step2():
window.chat_bridge.finish_message(MID)
QTimer.singleShot(1200, done)
def done():
app.quit()
QTimer.singleShot(2000, step1)
app.exec()
print("===== 测试结束(上方应看到 [生命周期]/[JS] 打印)=====")
+199
View File
@@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
"""中途切会话回归:agent 流式进行中切走 → 切回 → 时间线必须完整
(用户报告:切走再切回,正文/思考那一段显示不出来)
运行: python tests/smoke_midswitch.py
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
window = None
results = []
S = {} # 测试状态
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
CHECK_JS = r"""
(function() {
var mid = window.__msMid;
var w = document.getElementById(mid);
if (!w) return JSON.stringify({error: 'wrapper missing'});
var tl = w.querySelector('.reply-content');
var blocks = Array.prototype.map.call(tl.children, function(el) {
return el.className.split(' ')[0];
});
var texts = Array.prototype.map.call(
w.querySelectorAll('.md-segment'),
function(s) { return s.textContent || ''; });
var thinks = Array.prototype.map.call(
w.querySelectorAll('.think-content'),
function(s) { return s.textContent || ''; });
return JSON.stringify({blocks: blocks, texts: texts, thinks: thinks,
streaming: w.classList.contains('streaming')});
})()
"""
def fire(session, fn, *args):
"""模拟 worker 信号到达(UI 线程直接调 handler)"""
getattr(window, fn)(*([session] + list(args)))
def step1():
global window
window = MainWindow()
window.show()
QTimer.singleShot(3000, step2)
def step2():
db = window.db
sessA = db.create_session("切走测试A")
sessB = db.create_session("切走测试B")
A, B = sessA["id"], sessB["id"]
db.add_message(B, "user", "B 的问题", None)
# ---- 会话 A 开始流式(手动模拟事件到达)----
user_row = db.add_message(A, "user", "A 的问题", None)
mid = "msg-midswitch"
window.current_session_id = A
window.chat_bridge.create_message(mid, "assistant", "", "SW")
window._active_streams[A] = {
"msg_id": mid, "content": "", "reasoning": "",
"timeline": [], "tl_kind": None,
"parent_id": user_row["id"], "branch_info": None, "worker": None,
"previous_leaf_id": user_row["id"],
}
S.update(A=A, B=B, mid=mid)
window.browser.page().runJavaScript(f"window.__msMid = '{mid}';")
# 阶段 1A 前台):think1 + text1 + 工具
fire(A, "on_reasoning_received", "思考第一")
fire(A, "on_reasoning_received", "段内容")
fire(A, "on_chunk_received", "工具前正文")
fire(A, "_on_tool_started", "call-ms", "bash", '{"command":"echo ms"}')
QTimer.singleShot(800, switch_away)
def switch_away():
"""切到 BA 的流继续在后台跑"""
print("[test] 切换到 B ...", flush=True)
window.load_messages_to_web(S["B"])
# 阶段 2A 后台):think2 + text2(只应累积,不进 JS)
QTimer.singleShot(600, lambda: (
fire(S["A"], "on_reasoning_received", "思考第二"),
fire(S["A"], "on_chunk_received", "工具后正文"),
print("[test] 后台 token 已累积", flush=True),
QTimer.singleShot(600, switch_back)
))
def switch_back():
"""切回 A:应重建 wrapper + 恢复时间线 + 后续无缝续流"""
print("[test] 切回 A ...", flush=True)
window.load_messages_to_web(S["A"])
# ★ 切回后立即检查:恢复的正文/思考必须已同步上屏(不等 rAF/timer)
def check_right_away():
window.browser.page().runJavaScript(CHECK_JS, lambda res: (
print("[切回后立即]", str(res), flush=True),
check("切回后立即: 后台正文已同步上屏",
"工具后正文" in json.loads(str(res)).get("texts", [""] + [json.loads(str(res))["texts"][0] if json.loads(str(res)).get("texts") else ""])[0] or "工具后正文" in "".join(json.loads(str(res)).get("texts", []))),
QTimer.singleShot(300, phase3)
))
def phase3():
fire(S["A"], "on_chunk_received", " 续流正文")
QTimer.singleShot(600, finish_it)
QTimer.singleShot(400, check_right_away)
def finish_it():
window.on_reply_finished(S["A"])
QTimer.singleShot(400, step_check)
DIAG_JS = r"""
(function() {
var wrappers = Array.prototype.map.call(
document.querySelectorAll('.message-wrapper'),
function(w) { return w.id + ':' + w.className.split(' ')[1]; });
return JSON.stringify({wrappers: wrappers,
bufKeys: Object.keys(window.messageBuffer || {})});
})()
"""
def step_check():
def diag(res):
print("[diag]", str(res), flush=True)
window.browser.page().runJavaScript(CHECK_JS, on_check)
window.browser.page().runJavaScript(DIAG_JS, diag)
def on_check(res):
d = json.loads(str(res))
if "error" in d:
check("切回后 wrapper 存在", False, d["error"])
done()
return
blocks = d["blocks"]
texts = "".join(d["texts"])
thinks = "".join(d["thinks"])
print(f" blocks = {blocks}")
print(f" texts = {d['texts']}")
print(f" thinks = {d['thinks']}")
check("切回后 wrapper 存在", True)
check("块顺序与事件顺序一致: think→text→chip→think→text",
[b for b in blocks if b in
("think-block", "tool-chip", "md-segment")] ==
["think-block", "md-segment", "tool-chip", "think-block", "md-segment"],
str(blocks))
check("正文完整(切走前+后台+切回后)",
"工具前正文" in texts and "工具后正文" in texts
and "续流正文" in texts, str(d["texts"]))
check("思考完整(切走前+后台)",
"思考第一段内容" in thinks and "思考第二" in thinks,
str(d["thinks"]))
check("streaming 已收尾", not d["streaming"])
# 入库验证
row = window.db.get_message_chain(S["A"])
asst = [m for m in row if m["role"] == "assistant"]
ok_db = False
if asst:
tl = asst[-1].get("timeline")
if tl:
tl_list = json.loads(tl)
joined = "".join(e.get("text", "") for e in tl_list if e["t"] == "text")
ok_db = ("工具前正文" in joined and "工具后正文" in joined
and "续流正文" in joined)
check("DB 时间线完整(三段正文都在)", ok_db)
done()
def done():
try:
window.db.delete_session(S["A"])
window.db.delete_session(S["B"])
except Exception:
pass
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
QTimer.singleShot(500, step1)
app.exec()
+94
View File
@@ -0,0 +1,94 @@
"""offscreen 模式切换功能测试:DB 持久化 + 首条消息锁定 + worker 分派
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_mode.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# 铁律:测试不得污染真实 data/chat_history.db → DBManager 默认路径重定向到临时文件
import tempfile as _tf # noqa: E402
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(_tf.gettempdir(), f"haocode_test_smoke_mode_{os.getpid()}.db")
from PyQt6.QtWidgets import QApplication # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 须先于 QApplication import 完成)
app = QApplication(sys.argv)
ok = True
def check(name, cond):
global ok
print((" PASS " if cond else " FAIL ") + name)
if not cond:
ok = False
window = MainWindow()
# 本套件验证「默认锁定」语义:显式置 mode_switch=false,不受本机 data/config.json 影响
# (仅内存,不会写回配置文件)
window.config_data["mode_switch"] = False
window._refresh_mode_button()
# 1) 新建会话 → 未锁定
window.on_new_chat_clicked()
sid = window.current_session_id
check("新会话未锁定 (mode=None)", window._get_current_mode() is None)
check("按钮显示待选 Chat", "Chat" in window.btn_mode.text() and "🔒" not in window.btn_mode.text())
# 2) 浮动弹窗开/关 + 选择
window.show_mode_popup()
check("弹窗打开", window.mode_popup.isVisible())
window._select_mode("worker")
check("选 worker 后按钮更新", "Worker" in window.btn_mode.text())
check("选择后弹窗自动关闭", not window.mode_popup.isVisible())
# 3) 首条发送 → 锁定
window._lock_session_mode()
check("发送后锁定 worker", window.db.get_session_mode(sid) == "worker")
check("按钮显示锁定", "🔒" in window.btn_mode.text())
# 4) 锁定后不可改
window._select_mode("chat")
check("锁定后选择无效", window.db.get_session_mode(sid) == "worker")
# 5) worker 分派
w1 = window._create_stream_worker([], "worker")
w2 = window._create_stream_worker([], "chat")
from core.llm_engine import AgentWorker, ChatWorker # noqa: E402
check("worker 模式 → AgentWorker", isinstance(w1, AgentWorker))
check("chat 模式 → ChatWorker", isinstance(w2, ChatWorker))
check("ChatWorker 有基础信号", all(hasattr(w2, s) for s in
("chunk_received", "reasoning_received", "error_occurred")))
check("ChatWorker 无工具信号(chat 模式不暴露)",
not hasattr(w2, "tool_execution_started"))
# 6) 切回新会话 → 解锁
window.on_new_chat_clicked()
check("新会话恢复可选", window._get_current_mode() is None and "🔒" not in window.btn_mode.text())
# 7) 旧会话(无 mode 列值)→ 默认 chat 路径
window.load_messages_to_web(sid)
check("切回已锁定会话显示锁定", window._get_current_mode() == "worker" and "🔒" in window.btn_mode.text())
# 8) mode_switch=true → 中途切换放行(旧锁定会话解锁 + 选择立即落库)
window.config_data["mode_switch"] = True
window._refresh_mode_button()
check("开关开启后旧会话解锁显示", "🔒" not in window.btn_mode.text())
window._select_mode("chat")
check("开关开启后中途切换生效", window.db.get_session_mode(sid) == "chat")
window.config_data["mode_switch"] = False
try:
window.close()
except Exception:
pass
app.quit()
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====")
sys.exit(0 if ok else 1)
+201
View File
@@ -0,0 +1,201 @@
"""offscreen 冒烟测试:主窗口实例化 + 核心链路(不启动真实 LLM)
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_offscreen.py
"""
import os
import sys
import json
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost # 软渲染,离屏最稳
# 铁律:测试不得污染真实 data/chat_history.db → DBManager 默认路径重定向到临时文件
import tempfile as _tf # noqa: E402
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(_tf.gettempdir(), f"haocode_test_smoke_offscreen_{os.getpid()}.db")
import ctypes # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
# QtWebEngine 必须在 QApplication 创建前 import
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
results = []
def check(name, fn):
try:
fn()
print(f" PASS {name}")
results.append(True)
except Exception as e:
import traceback
traceback.print_exc()
print(f" FAIL {name}: {e}")
results.append(False)
window = None
page_ready = {"ok": False}
def on_page_load_progress(v):
pass
def on_js_console(level, msg, line, src):
pass
def try_load():
global window
try:
window = MainWindow()
page_ready["ok"] = True
except Exception:
import traceback
traceback.print_exc()
finish(False)
return
# MainWindow 构造完成时 HTML/JS 已就绪(loadFinished 可能早于连接)
QTimer.singleShot(6000, run_checks) # 等 JS 引擎 + 历史渲染完成
def run_checks():
# 1) 窗口已创建
check("MainWindow 实例化", lambda: (_ for _ in ()).throw(AssertionError("no window")) if window is None else None)
# 2) DB 链路
def db_chain():
assert window.db is not None
sessions = window.db.get_all_sessions()
assert isinstance(sessions, list)
check("DB 会话列表", db_chain)
# 3) build_api_contextP0 修复验证:不再有 reasoning 字段)
def ctx_build():
sid = window.current_session_id
if not sid:
return
msgs = window.build_api_context(sid)
assert isinstance(msgs, list)
for m in msgs:
assert "reasoning" not in m, f"reasoning 字段仍在 API payload 里: {m.get('role')}"
check("build_api_context 无 reasoning 字段", ctx_build)
# 4) CJK token 估算
def est():
n = window._estimate_token_count([
{"role": "user", "content": "你好,世界!这是一段中文测试。"},
{"role": "assistant", "content": "hello world " * 20},
])
assert isinstance(n, int) and n > 0
check("CJK token 估算", est)
# 5) AgentWorker 可构造 + 信号齐全
def worker():
from core.llm_engine import AgentWorker, TitleWorker
w = AgentWorker(window.current_provider, window.current_model,
[{"role": "user", "content": "x"}])
for sig in ("chunk_received", "reasoning_received", "error_occurred",
"tool_execution_started", "tool_execution_updated",
"tool_execution_finished", "context_compacted"):
assert hasattr(w, sig), sig
t = TitleWorker(window.current_provider, window.current_model,
[{"role": "user", "content": "x"}])
assert hasattr(t, "chunk_received")
check("AgentWorker/TitleWorker 构造", worker)
# 6) bridge 工具方法
def bridge():
b = window.chat_bridge
for m in ("tool_execution_started", "tool_execution_updated",
"tool_execution_finished", "show_note"):
assert hasattr(b, m), m
check("ChatBridge 工具事件方法", bridge)
# 7) 离屏渲染 JS 就绪
check("Web 页面加载完成", lambda: (_ for _ in ()).throw(AssertionError("page not ready")) if not page_ready["ok"] else None)
# 8) 🌟 KaTeX 公式渲染(真实页面上下文:资源加载 + [...] 供应商格式 + 行内 $)
def _run_js(js, timeout_s=10):
result = {"val": None, "done": False}
def on_ret(val):
if isinstance(val, str):
try:
val = json.loads(val)
except Exception:
pass
result["val"] = val
result["done"] = True
if hasattr(window.browser, "execute_js_async"): # WebView2 路径(cb 收 JSON 字符串)
window.browser.execute_js_async(js, on_ret)
else: # QtWebEngine 路径(cb 收 Python 对象)
window.browser.page().runJavaScript(js, on_ret)
t0 = time.time()
while not result["done"] and time.time() - t0 < timeout_s:
app.processEvents()
time.sleep(0.05)
assert result["done"], "JS 执行超时"
return result["val"]
def katex_render():
# 等页面 JS 就绪(WV2 冷启动可能慢;app.js 就绪时置 window.jsReady=true
for _ in range(30):
if _run_js("window.jsReady === true ? 1 : 0", timeout_s=3) == 1:
break
time.sleep(0.5)
else:
raise AssertionError("页面 JS 未就绪(jsReady")
ver = _run_js("typeof katex !== 'undefined' ? katex.version : null")
assert ver, "katex 全局缺失(资源加载失败?)"
js = ("(function(){var s = '结果:" + chr(92) + "n[" + chr(92) + "nP_4=" + chr(92)*2 + "operatorname{BRF}(M_4,M_5)" + chr(92) + "n]" + chr(92) + "n" + chr(92) + "n行内 $E=mc^2$ 结束。';"
"var html = safeHtml(marked.parse(s));return {"
"has: html.indexOf('katex') !== -1,"
"disp: (html.match(/katex-display/g) || []).length,"
'inline: (html.match(/class=\"katex\"/g) || []).length,'
"leaked: html.indexOf('@@K') !== -1,"
"vis: html.indexOf('katex-html') !== -1,"
"tex: html.indexOf('\\operatorname{BRF}') !== -1};})()")
r = _run_js(js)
assert isinstance(r, dict), f"意外返回: {r!r}"
assert r.get("has"), "未产生 KaTeX HTML"
assert r.get("disp", 0) >= 1, "块公式未渲染为 katex-display"
assert r.get("inline", 0) >= 1, "行内公式未渲染"
assert not r.get("leaked"), "占位符泄漏"
assert r.get("vis"), "缺少 katex-html 可视层(KaTeX 未真正渲染)"
assert r.get("tex"), "tex 未正确传入(转义错误)"
print(f" [info] KaTeX {ver} | display={r.get('disp')} inline={r.get('inline')}")
check("KaTeX 公式渲染([...] 供应商格式 + 行内 $)", katex_render)
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====")
try:
window.close()
except Exception:
pass
app.quit()
def finish(ok=None):
app.quit()
QTimer.singleShot(500, try_load)
QTimer.singleShot(90000, finish) # 总超时
app.exec()
sys.exit(0 if all(results) else 1)
+297
View File
@@ -0,0 +1,297 @@
# -*- coding: utf-8 -*-
"""时间线持久化冒烟测试(真实 DB + 真实 WebEngine 页面):
1. 流式事件 → stream_state 时间线累积(思考/文本/工具 按序)
2. 入库(timeline 列)→ 切会话重载 → DOM 时间线还原(工具气泡不丢)
3. build_api_context 从时间线重建完整 API 链(assistant+tool_calls+tool
4. 切回进行中的会话:restoreStreamingTimeline 续流
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_persist.py
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
results = []
window = None
test = {"sid": None, "mid": None, "done": False}
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
def finish():
if test["done"]:
return
test["done"] = True
# 清理测试会话
try:
if test["sid"] and window:
window.db.delete_session(test["sid"])
except Exception:
pass
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
# JS: 校验重载后的时间线 DOM
JS_VERIFY_RELOAD = r"""
(function() {
var out = {};
try {
var mid = '__MID__';
var wrapper = document.getElementById(mid);
if (!wrapper) return JSON.stringify({error: 'wrapper missing'});
var tl = wrapper.querySelector('.reply-content');
out.blocks = Array.prototype.map.call(tl.children, function(el) {
return el.className.split(' ')[0];
});
var chip = wrapper.querySelector('.tool-chip');
var st = chip ? chip.querySelector('.tool-chip-status') : null;
out.chipStatus = st ? st.textContent : null;
out.chipOk = st ? st.classList.contains('ok') : false;
out.chipCallId = chip ? chip.getAttribute('data-call-id') : null;
var segs = wrapper.querySelectorAll('.md-segment');
out.lastSegText = segs.length ? segs[segs.length - 1].textContent : '';
out.thinkCount = wrapper.querySelectorAll('.think-block').length;
out.thinkChevron = !!wrapper.querySelector('.think-block .chev');
} catch (e) {
out.error = String(e);
}
return JSON.stringify(out);
})()
"""
# JS: 续流测试
JS_RESUME_A = r"""
(function() {
try {
var mid = '__MID__';
createMessage(mid, 'assistant', '', 'Resume');
restoreStreamingTimeline(mid, '__TLJSON__');
// 续流:接着最后一段文本写;思考新开一段
appendToken(mid, '(续流文本)');
appendReasoning(mid, '续思考内容');
return 'resumeA-done';
} catch (e) {
return 'resumeA-err:' + String(e);
}
})()
"""
JS_RESUME_B = r"""
(function() {
try {
var mid = '__MID__';
finishMessage(mid);
var wrapper = document.getElementById(mid);
var tl = wrapper.querySelector('.reply-content');
var out = {};
out.blocks = Array.prototype.map.call(tl.children, function(el) {
return el.className.split(' ')[0];
});
var segs = wrapper.querySelectorAll('.md-segment');
out.lastSegText = segs.length ? segs[segs.length - 1].textContent : '';
out.thinkCount = wrapper.querySelectorAll('.think-block').length;
out.chipCount = wrapper.querySelectorAll('.tool-chip').length;
wrapper.parentNode.removeChild(wrapper);
return JSON.stringify(out);
} catch (e) {
return 'resumeB-err:' + String(e);
}
})()
"""
def run_checks():
db = window.db
# ---------- 建测试会话 + 模拟流式事件累积 ----------
sess = db.create_session("持久化测试会话")
sid = sess["id"]
test["sid"] = sid
mid = "msg-persist-test"
test["mid"] = mid
user_row = db.add_message(sid, "user", "测试问题", None)
user_id = user_row["id"]
window.current_session_id = sid
window.chat_bridge.create_message(mid, "assistant", "", "PersistTest")
st = {
"msg_id": mid, "content": "", "reasoning": "",
"timeline": [], "tl_kind": None,
"parent_id": user_id, "branch_info": None, "worker": None,
"previous_leaf_id": user_id,
}
window._active_streams[sid] = st
# 事件序列:思考 → 文本 → 工具 → 思考 → 文本
window.on_reasoning_received(sid, "先看一下")
window.on_reasoning_received(sid, "目录结构。")
window.on_chunk_received(sid, "我来执行")
window._on_tool_started(sid, "call-p1", "bash", '{"command": "echo hi"}')
window._on_tool_updated(sid, "call-p1", "hi\n")
window._on_tool_finished(sid, "call-p1", "bash", True,
"$ echo hi\nhi\n[exit 0]")
window.on_reasoning_received(sid, "输出正常。")
window.on_chunk_received(sid, "任务完成。")
tl = st["timeline"]
check("时间线累积: 5 段", len(tl) == 5, str(tl))
check("时间线类型序 think/text/tool/think/text",
[e["t"] for e in tl] == ["think", "text", "tool", "think", "text"],
str([e["t"] for e in tl]))
tool_e = tl[2] if len(tl) > 2 else {}
check("工具条目定格 ok+result",
tool_e.get("ok") is True and "[exit 0]" in tool_e.get("result", ""),
str(tool_e))
check("聚合 content/reasoning 正确",
st["content"] == "我来执行任务完成。"
and st["reasoning"] == "先看一下目录结构。输出正常。",
f"{st['content']!r} / {st['reasoning']!r}")
tl_json = json.dumps(tl, ensure_ascii=False)
# ---------- 入库 ----------
row = db.add_message(sid, "assistant", st["content"], user_id,
reasoning=st["reasoning"], msg_id=mid,
timeline=tl_json)
check("DB timeline 列写入", row.get("timeline") == tl_json)
# ---------- 切走再切回(重载) ----------
del window._active_streams[sid] # 模拟流已结束(先于重载,避免 live-restore 重复渲染)
window.load_messages_to_web(sid, show_loading=False)
def verify_reload(res):
data = json.loads(str(res))
if "error" in data:
check("重载 DOM 校验", False, data["error"])
finish()
return
expected = ["think-block", "md-segment", "tool-chip",
"think-block", "md-segment"]
check("重载: 时间线块序还原", data["blocks"] == expected,
str(data["blocks"]))
check("重载: 工具气泡保留且成功",
data["chipOk"] and data["chipStatus"] == "✓ 完成"
and data["chipCallId"] == "call-p1",
f"{data['chipStatus']} {data['chipCallId']}")
check("重载: 思考块 x2 + 末段文本",
data["thinkCount"] == 2 and "任务完成。" in data["lastSegText"],
f"think={data['thinkCount']} seg={data['lastSegText']!r}")
check("重载: SVG 箭头", data["thinkChevron"])
# ---------- build_api_context 重建 ----------
api = window.build_api_context(sid)
roles = [m["role"] for m in api]
check("API 链: user→assistant→tool→assistant",
roles == ["user", "assistant", "tool", "assistant"],
str(roles))
asst1 = api[1] if len(api) > 1 else {}
check("API: 首条 assistant 带 tool_calls",
asst1.get("content") == "我来执行"
and len(asst1.get("tool_calls", [])) == 1
and asst1["tool_calls"][0]["function"]["name"] == "bash",
str(asst1)[:200])
check("API: tool 消息 tool_call_id 对齐",
len(api) > 2 and api[2].get("tool_call_id") == "call-p1"
and "[exit 0]" in api[2].get("content", ""),
str(api[2] if len(api) > 2 else {})[:200])
check("API: 末条 assistant = 最终回答",
len(api) > 3 and api[3].get("content") == "任务完成。")
check("API: 无 reasoning 字段",
all("reasoning" not in m for m in api))
# ---------- 切回进行中会话: 续流 ----------
tljs = json.dumps(tl_json)
js_a = (JS_RESUME_A
.replace("'__MID__'", "'msg-resume-test'")
.replace("restoreStreamingTimeline(mid, '__TLJSON__')",
f"restoreStreamingTimeline(mid, {tljs})"))
js_b = JS_RESUME_B.replace("'__MID__'", "'msg-resume-test'")
def verify_resume_data(d2):
if "error" in d2:
check("续流 DOM 校验", False, d2["error"])
finish()
return
expected2 = ["think-block", "md-segment", "tool-chip",
"think-block", "md-segment", "think-block"]
check("续流: 块序正确", d2["blocks"] == expected2,
str(d2["blocks"]))
check("续流: 文本并入末段",
"任务完成。(续流文本)" in d2["lastSegText"],
d2["lastSegText"])
check("续流: 思考 x3 + 工具 x1",
d2["thinkCount"] == 3 and d2["chipCount"] == 1,
f"think={d2['thinkCount']} chip={d2['chipCount']}")
finish()
def run_b():
def got_b(v):
vstr = "" if v is None else str(v)
print(f" [resume B] {vstr[:120]}")
if vstr.startswith("resumeB-err"):
check("续流 finishMessage", False, vstr)
finish()
return
try:
d2 = json.loads(vstr)
except Exception:
check("续流 DOM 校验", False, repr(vstr)[:120])
finish()
return
verify_resume_data(d2)
window.browser.page().runJavaScript(js_b, got_b)
def run_a():
def got_a(v):
vstr = "" if v is None else str(v)
print(f" [resume A] {vstr[:120]}")
if vstr.startswith("resumeA-err"):
check("续流 restore", False, vstr)
finish()
return
QTimer.singleShot(300, run_b)
window.browser.page().runJavaScript(js_a, got_a)
QTimer.singleShot(300, run_a)
window.browser.page().runJavaScript(
JS_VERIFY_RELOAD.replace("'__MID__'", f"'{mid}'"), verify_reload)
def main():
global window
try:
window = MainWindow()
except Exception:
import traceback
traceback.print_exc()
app.quit()
return
QTimer.singleShot(6000, run_checks)
QTimer.singleShot(45000, lambda: (check("超时", False, "45s 未完成"),
finish()) if not test["done"] else None)
app.exec()
if __name__ == "__main__":
main()
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""验证 probeStream 探针在真实 WebEngine 页返回正确数据"""
import os, sys
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt6.QtWidgets import QApplication
import PyQt6.QtWebEngineWidgets # noqa
from PyQt6.QtCore import QTimer
from ui.views.main_window import MainWindow
PASS = 0
FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {detail}")
app = QApplication(sys.argv)
window = MainWindow()
window.resize(1280, 800)
window.show()
PROBE_JS = r"""
(function() {
var m = 'probe-test-' + Date.now();
createMessage(m, 'assistant', '', 'LG');
appendReasoning(m, '思考探针内容');
appendToken(m, '正文探针 ');
appendToken(m, '第二段');
// 同步强制渲染
if (typeof forceRenderNow === 'function') forceRenderNow(m);
var res = probeStream(m);
finishMessage(m);
return res;
})()
"""
def step1():
window.browser.page().runJavaScript(PROBE_JS, on_probe)
def on_probe(res):
import json
d = json.loads(str(res))
print(" probe:", str(d)[:300])
check("probe 返回缓冲段", len(d.get("segs", [])) >= 1, str(d))
if d.get("segs"):
s0 = d["segs"][0]
check("缓冲长度>0", s0["b"] > 0, str(s0))
check("DOM 已写入", s0["d"] > 0, str(s0))
check("思考段探针", len(d.get("thinks", [])) >= 1, str(d))
check("无 JS 异常", "err" not in d, str(d))
print(f"===== {'ALL PASS' if FAIL == 0 else 'HAS FAILURES'}: {PASS}/{PASS+FAIL} =====")
app.quit()
QTimer.singleShot(1500, step1)
app.exec()
sys.exit(1 if FAIL else 0)
+127
View File
@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""用 DB 里用户真实消息内容回放流式渲染,验证新路径是否正确。
每个 chunk 走独立 runJavaScript(模拟 Python 逐 token 推送的真实路径)。"""
import os, sys, json, sqlite3
# 不加 --disable-gpu:复现真实应用的 GPU 渲染环境
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt6.QtWidgets import QApplication
import PyQt6.QtWebEngineWidgets # noqa
from PyQt6.QtCore import QTimer
from ui.views.main_window import MainWindow
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASS = 0
FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {detail}")
def esc(s):
return s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
# 取用户真实消息
conn = sqlite3.connect(os.path.join(ROOT, "data", "chat_history.db"))
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT content, timeline FROM messages WHERE id LIKE 'msg-99235c06%' "
"ORDER BY created_at DESC LIMIT 1").fetchone()
assert row, "DB 中找不到测试消息"
timeline = json.loads(row["timeline"] or "[]")
print(f"消息: content={len(row['content'])}c, timeline={len(timeline)}")
MID = "repro-real"
# 构造回放脚本序列:think/text 按 20 字符切块,每批 20 个 chunk 一次 JS 调用
CHUNK = 20
BATCH = 20
steps = [] # JS 片段列表
for e in timeline:
t = e.get("t")
txt = e.get("text", "")
if t in ("think", "text") and txt:
for i in range(0, len(txt), CHUNK):
fn = "appendReasoning" if t == "think" else "appendToken"
steps.append(f"{fn}('{MID}', '{esc(txt[i:i+CHUNK])}');")
steps_batched = [chr(10).join(steps[i:i+BATCH]) for i in range(0, len(steps), BATCH)]
app = QApplication(sys.argv)
window = MainWindow()
window.resize(1280, 800)
window.show()
state = {"idx": 0, "probed": False}
def next_batch():
"""一次送一批(20 个 chunk 拼接在一个 JS 任务里)"""
if state["idx"] >= len(steps_batched):
QTimer.singleShot(300, mid_probe)
return
bi = state["idx"]
js = steps_batched[bi]
total = len(steps_batched)
def done(r, bi=bi):
state["idx"] = bi + 1
# 过半时探针一次
if not state["probed"] and bi + 1 >= total // 2:
state["probed"] = True
window.browser.page().runJavaScript(
f"probeStream('{MID}')", on_mid_probe)
else:
QTimer.singleShot(0, next_batch)
window.browser.page().runJavaScript(js, done)
def on_mid_probe(res):
d = json.loads(str(res))
print(" [过半探针]", str(d)[:400])
check("中途: 缓冲已累积", any(s["b"] > 0 for s in d.get("segs", [])), str(d))
check("中途: DOM 已写入部分正文",
any(s["d"] > 0 for s in d.get("segs", [])), str(d))
check("中途: 思考 DOM 已写入",
any(t["d"] > 0 for t in d.get("thinks", [])), str(d))
finish_seq()
def mid_probe():
window.browser.page().runJavaScript(f"probeStream('{MID}')", on_mid_probe)
def finish_seq():
window.browser.page().runJavaScript(
f"finishMessage('{MID}'); probeStream('{MID}')", on_final)
def on_final(res):
d = json.loads(str(res))
print(" [最终探针]", str(d)[:400])
tot_b = sum(s["b"] for s in d.get("segs", []))
tot_d = sum(s["d"] for s in d.get("segs", []))
check("最终: 正文缓冲完整", tot_b > 500, f"b={tot_b}")
check("最终: 正文 DOM 完整", tot_d > 500, f"d={tot_d}")
check("最终: 思考 DOM 完整",
sum(t["d"] for t in d.get("thinks", [])) > 500,
str([t['d'] for t in d.get('thinks', [])]))
# DOM 实际包含关键子串
window.browser.page().runJavaScript(
"(function(){ var w = document.getElementById('" + MID + "');"
" return w ? w.textContent.length : -1; })()", on_text)
def on_text(res):
n = int(str(res) or 0)
check("DOM 总文本量正常", n > 1000, f"total={n}")
print(f"===== {'ALL PASS' if FAIL == 0 else 'HAS FAILURES'}: {PASS}/{PASS+FAIL} =====")
app.quit()
def start():
window.browser.page().runJavaScript(
f"createMessage('{MID}', 'assistant', '', 'Real');",
lambda r: QTimer.singleShot(200, next_batch))
QTimer.singleShot(1500, start)
app.exec()
sys.exit(1 if FAIL else 0)
+191
View File
@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
"""时间线 DOM 冒烟测试(真实 WebEngine 页面内执行):
1. 流式事件序列 → 思考/正文/工具按事件顺序穿插
2. 工具 chip 按 call_id 对号入座 + ok 状态(修复反转 bug)
3. SVG 箭头存在、流式光标移除
4. 历史消息静态路径不受影响
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_timeline.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 已先导入)
app = QApplication(sys.argv)
results = []
window = None
done = {"ok": False}
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
def js(s):
return s
JS_SIM = js(r"""
(function() {
var out = {};
try {
// ---- 1. 流式时间线 ----
var mid = 'tl-test-' + Date.now();
createMessage(mid, 'assistant', '', 'TL');
var wrapper = document.getElementById(mid);
appendReasoning(mid, 'think part 1 ');
appendReasoning(mid, 'more thinking');
appendToken(mid, 'before tool text ');
toolExecutionStarted(mid, 'call-1', 'bash', JSON.stringify({command: 'echo hi'}));
toolExecutionUpdated(mid, 'call-1', 'hi\n');
toolExecutionFinished(mid, 'call-1', 'bash', true, '$ echo hi\nhi\n[exit 0]');
appendToken(mid, ' after tool');
appendReasoning(mid, ' second thinking');
appendToken(mid, ' final answer');
finishMessage(mid);
var tl = wrapper.querySelector('.reply-content');
out.blocks = Array.prototype.map.call(tl.children, function(el) {
return el.className.split(' ')[0];
});
var chip = wrapper.querySelector('.tool-chip');
var status = chip ? chip.querySelector('.tool-chip-status') : null;
out.chipStatusText = status ? status.textContent : null;
out.chipOk = status ? status.classList.contains('ok') : false;
out.chipBrief = chip ? (chip.querySelector('.tool-chip-brief') || {}).textContent : null;
out.chipCallId = chip ? chip.getAttribute('data-call-id') : null;
out.thinkChevron = !!wrapper.querySelector('.think-block .chev');
out.chipChevron = !!wrapper.querySelector('.tool-chip .chev');
out.typingRemoved = !wrapper.querySelector('.streaming-typing');
out.streamClassOff = !wrapper.classList.contains('streaming');
out.thinkBlocks = wrapper.querySelectorAll('.think-block').length;
out.mdSegs = wrapper.querySelectorAll('.md-segment').length;
// ---- 2. 历史消息静态路径 ----
var mid2 = 'tl-hist-' + Date.now();
createMessage(mid2, 'assistant', 'hello **world**', 'TL');
insertThinkBlock(mid2, 'history thinking');
finishMessage(mid2);
var w2 = document.getElementById(mid2);
var content2 = w2.querySelector('.message-content');
out.histOrder = Array.prototype.map.call(content2.children, function(el) {
return el.className.split(' ')[0];
});
out.histBold = w2.querySelector('.reply-content strong') !== null;
out.histThinkChevron = !!w2.querySelector('.think-block .chev');
out.histThinkLabel = (w2.querySelector('.think-label') || {}).textContent;
// ---- 3. 失败工具 chip(反转修复验证)----
var mid3 = 'tl-fail-' + Date.now();
createMessage(mid3, 'assistant', '', 'TL');
toolExecutionStarted(mid3, 'call-x', 'bash', '{"command":"false"}');
toolExecutionFinished(mid3, 'call-x', 'bash', false, 'boom');
var w3 = document.getElementById(mid3);
var st3 = w3.querySelector('.tool-chip-status');
out.failChipFail = st3 ? st3.classList.contains('fail') : false;
out.failChipText = st3 ? st3.textContent : null;
finishMessage(mid3);
// 清理
[mid, mid2, mid3].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.parentNode.removeChild(el);
});
} catch (e) {
out.error = String(e) + ' | ' + (e.stack || '').split('\n')[1];
}
return JSON.stringify(out);
})()
""")
def run_js_check():
page = window.browser.page() if hasattr(window, "browser") else None
if page is None:
# 找 QWebEnginePage
from PyQt6.QtWebEngineWidgets import QWebEngineView
views = window.findChildren(QWebEngineView)
page = views[0].page() if views else None
if page is None:
check("页面句柄", False, "no page")
finish()
return
def on_result(res):
try:
import json
data = json.loads(str(res))
except Exception as e:
check("JS 执行", False, f"bad json: {res} ({e})")
finish()
return
if "error" in data:
check("JS 执行", False, data["error"])
finish()
return
# 期望时间线: think → text → chip → text → think → text
expected = ["think-block", "md-segment", "tool-chip",
"md-segment", "think-block", "md-segment"]
check("时间线块顺序(思考/正文/工具穿插)", data["blocks"] == expected,
str(data["blocks"]))
check("两个思考段 + 三个正文段",
data["thinkBlocks"] == 2 and data["mdSegs"] == 3,
f"think={data['thinkBlocks']} segs={data['mdSegs']}")
check("工具 chip 成功状态(非反转)",
data["chipOk"] and data["chipStatusText"] == "✓ 完成",
f"{data['chipStatusText']} ok={data['chipOk']}")
check("chip 摘要=结果首行", data["chipBrief"] == "$ echo hi",
str(data["chipBrief"]))
check("chip call_id 贯通", data["chipCallId"] == "call-1",
str(data["chipCallId"]))
check("SVG 箭头(思考+工具)", data["thinkChevron"] and data["chipChevron"])
check("流式光标已移除 + streaming 类移除",
data["typingRemoved"] and data["streamClassOff"])
check("失败 chip 标 ✗", data["failChipFail"] and data["failChipText"] == "✗ 失败",
f"{data['failChipText']}")
check("历史消息: 思考在正文前",
data["histOrder"].index("think-block") < data["histOrder"].index("reply-content"),
str(data["histOrder"]))
check("历史消息: markdown 正常解析", data["histBold"])
check("历史消息: 思考块带 SVG 箭头+标签",
data["histThinkChevron"] and data["histThinkLabel"] == "已完成深度思考",
str(data["histThinkLabel"]))
finish()
page.runJavaScript(JS_SIM, on_result)
def finish():
done["ok"] = True
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
def main():
global window
try:
window = MainWindow()
except Exception:
import traceback
traceback.print_exc()
app.quit()
return
QTimer.singleShot(6000, run_js_check)
QTimer.singleShot(25000, lambda: (check("超时", False, "25s 未完成"), finish())
if not done["ok"] else None)
app.exec()
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
"""
tool_bash 实时输出(增量流)单测 —— 纯函数级,无 UI 依赖
本轮改动核心:communicate(timeout=1) 阻塞式收集
→ stdout/stderr reader 线程 + 队列 + 主循环抽干回调 on_update
本测试锁死「改动没有破坏任何既有语义」+「输出真的是实时的」。
覆盖:
T1 增量流:on_update 被多次调用(不是结束时一次性给)
T2 实时性:第一块输出到达时刻 << 总耗时(证明是流式而非收尾补发)
T3 上下文文本格式完全不变($ cmd / stdout / [stderr] / [exit N] (Ns)
T4 stderr 实时流带 [stderr] 分隔标记,且最终文本仍有 [stderr] 段
T5 每 1 秒读秒回调 on_timer 仍然工作
T6 超时:杀进程树 + 「命令超时(>Ns)已终止」+ 实际耗时远小于命令时长
T7 中止:AbortSignal.aborted → 「操作已中止」
T8 50KB 截断仍然生效
T9 退出非 0 → is_error,且 exit code 写入 details
T10 空命令 → 参数校验错误
T11 无输出命令:不产生 on_update,但读秒正常
T12 最终文本 = 全部 stdout 拼接(不丢不重)
运行: PYTHONIOENCODING=utf-8 python tests/test_bash_stream.py
"""
import os
import re
import sys
import time
import tempfile
import shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.agent.tools import tool_bash # noqa: E402
from core.agent.types import AbortSignal # noqa: E402
RESULTS = []
def check(name, cond, extra=""):
RESULTS.append((name, bool(cond)))
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
_TMP = tempfile.mkdtemp(prefix="hocode_bashstream_")
PY = sys.executable
def run(script=None, timeout=None, abort_after=None, shell_script=None):
"""把脚本写成临时文件再执行(避开 cmd 引号地狱)。
返回 (result, updates, first_t, dur, timer_calls)"""
path = None
if script is not None:
path = os.path.join(_TMP, f"s_{int(time.time() * 1000000) % 10**9}.py")
with open(path, "w", encoding="utf-8") as f:
f.write(script)
cmd = f'"{PY}" -u "{path}"'
else:
cmd = shell_script
updates = []
first_t = [None]
t0 = time.time()
def on_update(text):
if first_t[0] is None:
first_t[0] = time.time() - t0
updates.append(text)
timers = []
def on_timer(elapsed, total):
timers.append((elapsed, total))
sig = AbortSignal()
if abort_after is not None:
import threading
def _aborter():
time.sleep(abort_after)
sig.abort("test")
threading.Thread(target=_aborter, daemon=True).start()
args = {"command": cmd}
if timeout is not None:
args["timeout"] = timeout
res = tool_bash("call_test", args, sig, on_update,
{"cwd": _TMP, "shell": True, "on_timer": on_timer})
dur = time.time() - t0
return res, updates, first_t[0], dur, timers
def text_of(res):
c = res.content
if isinstance(c, str):
return c
return "".join(x.get("text", "") for x in c if isinstance(x, dict))
# ======================================================================
try:
# ---------- T1/T2/T3/T12: 流式 + 上下文格式 ----------
res, ups, first_t, dur, timers = run(
"import sys, time\n"
"for i in range(3):\n"
" print('LINE%d' % i, flush=True)\n"
" time.sleep(0.5)\n", timeout=30)
txt = text_of(res)
check("T1.1 增量流被多次回调(>=3 块)", len(ups) >= 3, f"updates={len(ups)} {ups!r}")
check("T1.2 每块都是本次输出的一部分", all(u.strip() for u in ups), repr(ups[:3]))
check("T2.1 第一块在 1s 内到达(实时,不是收尾补发)",
first_t is not None and first_t < 1.0, f"first_t={first_t}")
check("T2.2 第一块明显早于总耗时",
first_t is not None and (dur - first_t) > 0.6, f"first={first_t} dur={dur:.2f}")
check("T3.1 上下文文本以 $ 命令开头", txt.startswith("$ "), repr(txt[:60]))
check("T3.2 含 [exit 0] 与耗时",
re.search(r"\[exit 0\] \(\d+\.\d+s\)", txt) is not None, repr(txt[-60:]))
check("T12.1 三行输出全部进入最终文本,不丢不重",
all(f"LINE{i}" in txt for i in range(3)) and txt.count("LINE0") == 1, repr(txt))
check("T3.3 is_error=False", res.is_error is False)
check("T3.4 details 带 exit_code", res.details.get("exit_code") == 0, str(res.details))
# ---------- T4: stderr 实时 + 最终段 ----------
res, ups, first_t, dur, timers = run(
"import sys\nprint('OUT1', flush=True)\nprint('ERR1', file=sys.stderr, flush=True)\n",
timeout=30)
txt = text_of(res)
joined = "".join(ups)
check("T4.1 实时流里有 [stderr] 分隔标记", "[stderr]" in joined, repr(joined))
check("T4.2 最终文本含 [stderr] 段与内容",
"[stderr]" in txt and "ERR1" in txt, repr(txt))
check("T4.3 stdout 内容也在最终文本里", "OUT1" in txt, repr(txt))
# ---------- T5/T11: 读秒 ----------
res, ups, first_t, dur, timers = run(
"import time\ntime.sleep(2.6)\nprint('done', flush=True)\n", timeout=30)
check("T5.1 长命令期间读秒回调(>=2 次)", len(timers) >= 2, f"timers={timers}")
check("T5.2 读秒总量正确", all(t[1] == 30 for t in timers), str(timers))
res, ups, first_t, dur, timers = run(
"import time\ntime.sleep(1.5)\n", timeout=30)
check("T11.1 无输出命令不产生 on_update", len(ups) == 0, repr(ups))
check("T11.2 无输出命令仍有读秒", len(timers) >= 1, str(timers))
# ---------- T6: 超时杀树 ----------
res, ups, first_t, dur, timers = run(
"import time\nprint('start', flush=True)\ntime.sleep(30)\n", timeout=2)
txt = text_of(res)
check("T6.1 超时文案", "命令超时(>2s)已终止" in txt, repr(txt))
check("T6.2 真的提前返回(耗时应 <10s)", dur < 10, f"dur={dur:.2f}")
check("T6.3 超时结果是 is_error", res.is_error is True)
# ---------- T7: 中止 ----------
res, ups, first_t, dur, timers = run(
"import time\ntime.sleep(30)\n", timeout=60, abort_after=1.5)
txt = text_of(res)
check("T7.1 中止文案", "操作已中止" in txt, repr(txt))
check("T7.2 中止后迅速返回(<8s", dur < 8, f"dur={dur:.2f}")
# ---------- T8: 50KB 截断 ----------
res, ups, first_t, dur, timers = run(
"import sys\n"
"line = 'X' * 100 + '\\n'\n"
"for _ in range(1200):\n"
" sys.stdout.write(line)\n"
"sys.stdout.flush()\n", timeout=30)
txt = text_of(res)
check("T8.1 超过 50KB 触发截断标记", "[输出超过 50KB 已截断]" in txt, repr(txt[-80:]))
check("T8.2 截断后字节数 <= 50KB+余量",
len(txt.encode("utf-8")) <= 50 * 1024 + 200, str(len(txt.encode("utf-8"))))
# ---------- T9: 非 0 退出 ----------
res, ups, first_t, dur, timers = run("import sys\nsys.exit(3)\n", timeout=30)
txt = text_of(res)
check("T9.1 exit 3 写入文本", "[exit 3]" in txt, repr(txt))
check("T9.2 is_error=True", res.is_error is True)
check("T9.3 details.exit_code=3", res.details.get("exit_code") == 3, str(res.details))
# ---------- T10: 空命令 ----------
res = tool_bash("c", {"command": " "}, AbortSignal(), None,
{"cwd": _TMP, "shell": True})
check("T10.1 空命令被拒", "command 不能为空" in text_of(res), repr(text_of(res)))
# ---------- T13: 秒级短命令仍正常(回归:不因线程化而变慢/丢输出) ----------
res, ups, first_t, dur, timers = run(shell_script="echo hello_from_cmd")
txt = text_of(res)
check("T13.1 短命令输出正确", "hello_from_cmd" in txt, repr(txt))
check("T13.2 短命令耗时 <3s", dur < 3, f"dur={dur:.2f}")
check("T13.3 短命令实时流也拿到了输出", "hello_from_cmd" in "".join(ups), repr(ups))
finally:
shutil.rmtree(_TMP, ignore_errors=True)
failed = [n for n, ok in RESULTS if not ok]
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
sys.exit(0 if not failed else 1)
+287
View File
@@ -0,0 +1,287 @@
# -*- coding: utf-8 -*-
"""
离屏验证:压缩持久化(链上标记点)
背景:压缩此前只改内存 agent.state.messages,下一轮提问从 DB 全量重建历史
→「压缩等于没有压缩」。现:压缩成功后在链上插标记行(role="compaction"
is_ignored=1),保留首条改指到标记;build_api_context 在最后一个标记处截断、
以摘要(user 消息)替代之前一切。历史行全部保留,前端切会话渲染不受影响。
"""
import os, sys, json, tempfile, types
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from core.db_manager import DBManager # noqa: E402
from core.agent.types import AgentMessage, AgentConfig, ModelConfig # noqa: E402
from core.agent.stream_fn import from_openai_messages, to_openai_messages # noqa: E402
from core.agent.compaction import prepare_compaction, CompactionSettings # noqa: E402
from core.agent.context import estimate_context_tokens # noqa: E402
from core.agent.recovery import AgentRunner, _cut_ids_of # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402 纯方法 unbound 调用,不实例化
RESULTS = []
def check(name, cond, extra=""):
RESULTS.append((name, bool(cond)))
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
TMP = os.path.join(tempfile.gettempdir(), f"haocode_compact_persist_{os.getpid()}.db")
if os.path.exists(TMP):
os.remove(TMP)
db = DBManager(TMP)
sess = db.create_session("压缩持久化测试")
sid = sess["id"]
def add(role, content, parent):
return db.add_message(session_id=sid, role=role, content=content, parent_id=parent)
# 链:r1(user 旧) → r2(assistant 旧) → r3(user 旧) → r4(assistant 保留) → r5(user 保留)
r1 = add("user", "旧问题1", None)
r2 = add("assistant", "旧回答1", r1["id"])
r3 = add("user", "旧问题2", r2["id"])
r4 = add("assistant", "保留回答", r3["id"])
r5 = add("user", "保留问题", r4["id"])
leaf_before = db.get_session_leaf(sid)
check("T0.初始叶子=最后一条", leaf_before == r5["id"])
MARK_SUMMARY = "## 摘要\n上方对话已摘要:用户问了两个旧问题。"
# ---------- T1:标记插入(原子,不动叶子) ----------
mid = db.insert_compaction_mark(sid, MARK_SUMMARY, r3["id"], r4["id"],
json.dumps({"path": "pre_prompt"}, ensure_ascii=False))
chain = db.get_message_chain(sid)
check("T1.标记行已插入链上", mid is not None and any(m["id"] == mid for m in chain))
check("T1.链=6行且顺序正确",
[m["id"] for m in chain] == [r1["id"], r2["id"], r3["id"], mid, r4["id"], r5["id"]],
[m["id"] for m in chain])
check("T1.叶子指针不变", db.get_session_leaf(sid) == leaf_before)
check("T1.保留首条改指标记", chain[4]["parent_id"] == mid)
mark_row = next(m for m in chain if m["id"] == mid)
check("T1.标记 role/is_ignored", mark_row["role"] == "compaction" and mark_row["is_ignored"] == 1)
# ---------- T5:分支一致性(无幻影分支) ----------
check("T5.旧N唯一子=标记", [m["id"] for m in db.get_branch_info(r3["id"])] == [mid])
check("T5.标记唯一子=保留首条", [m["id"] for m in db.get_branch_info(mid)] == [r4["id"]])
# ---------- T2build_api_context 截断 ----------
class _Dummy:
db = db
api = MainWindow.build_api_context(_Dummy(), sid)
api_text = json.dumps([m.get("content") for m in api], ensure_ascii=False)
check("T2.首条=摘要user消息(带_kind)",
api and api[0]["role"] == "user" and api[0]["content"] == MARK_SUMMARY
and api[0].get("_kind") == "compaction_summary", api[:1])
check("T2.保留行仍在", "保留回答" in api_text and "保留问题" in api_text)
check("T2.切点前行消失",
"旧问题1" not in api_text and "旧回答1" not in api_text and "旧问题2" not in api_text)
check("T2.共3条(摘要+2保留)", len(api) == 3, len(api))
# ---------- T3kind 传播 → 二次压缩走迭代摘要 ----------
msgs = from_openai_messages(api)
check("T3.摘要 kind 还原", msgs and msgs[0].kind == "compaction_summary",
[m.kind for m in msgs])
prep = prepare_compaction(msgs, CompactionSettings(reserve_tokens=1000,
keep_recent_tokens=2))
check("T3.走 previous_summary 迭代路径",
prep is not None and prep.previous_summary == MARK_SUMMARY)
# ---------- T4:双标记取最后一个 ----------
mid2 = db.insert_compaction_mark(sid, "摘要2", r4["id"], r5["id"])
api2 = MainWindow.build_api_context(_Dummy(), sid)
api2_text = json.dumps([m.get("content") for m in api2], ensure_ascii=False)
check("T4.取最后一个标记", api2[0]["content"] == "摘要2" and len(api2) == 2, len(api2))
check("T4.只留第二个切点之后", "保留问题" in api2_text and "保留回答" not in api2_text)
# ---------- T6_cut_ids_of 切点计算(单元) ----------
A = AgentMessage(role="user", content="x", db_msg_id="a")
B = AgentMessage(role="assistant", content="y", db_msg_id="")
C = AgentMessage(role="toolResult", content="z", db_msg_id="")
S = AgentMessage(role="user", content="SUM", kind="compaction_summary")
check("T6.尾巴首条无DB行→first为空(UI跳过插标记)",
_cut_ids_of([A, B, C], [S, B, C]) == ("a", ""))
B2 = AgentMessage(role="assistant", content="y", db_msg_id="b")
check("T6.正常切点", _cut_ids_of([A, B2, C], [S, B2, C]) == ("a", "b"))
D = AgentMessage(role="assistant", content="d", db_msg_id="d")
check("T6.尾巴不在旧列表→不插", _cut_ids_of([A, B2, C], [S, D]) == ("", ""))
check("T6.无之前消息→无切点", _cut_ids_of([B2, C], [S, B2, C]) == ("", ""))
# T6c:⚠️ 同 id 回放序列(timeline 多条目共享一行 id)—— 曾导致 DB 自环卡死
Pm = AgentMessage(role="user", content="p", db_msg_id="p")
A1 = AgentMessage(role="assistant", content="a1", db_msg_id="a")
A2 = AgentMessage(role="assistant", content="a2", db_msg_id="a")
A3 = AgentMessage(role="toolResult", content="a3", db_msg_id="a")
Qm = AgentMessage(role="user", content="q", db_msg_id="q")
cut = _cut_ids_of([Pm, A1, A2, A3, Qm], [S, A2, A3, Qm])
check("T6c.同id回放切点回退到行头(cut_before≠first_retained)",
cut == ("p", "a"), cut)
cut2 = _cut_ids_of([Pm, A1, A2, A3, Qm], [S, A3, Qm])
check("T6c.尾巴起点更深也回退到行头", cut2 == ("p", "a"), cut2)
# ---------- T10insert_compaction_mark 自环守卫 ----------
bad = db.insert_compaction_mark(sid, "S", r5["id"], r5["id"])
check("T10.cut==retained 拒绝插入(防自环)", bad is None)
# ---------- T11get_message_chain 环守卫(手工造 2 环) ----------
sid2 = db.create_session("环守卫")["id"]
x1 = db.add_message(session_id=sid2, role="user", content="x1", parent_id=None)
x2 = db.add_message(session_id=sid2, role="assistant", content="x2", parent_id=x1["id"])
with db.get_connection() as conn:
conn.execute("UPDATE messages SET parent_id=? WHERE id=?", (x1["id"], x2["id"]))
conn.execute("UPDATE messages SET parent_id=? WHERE id=?", (x2["id"], x1["id"]))
conn.commit()
import time as _t
t0 = _t.time()
chain11 = db.get_message_chain(sid2)
dur = _t.time() - t0
check("T11.手工2环不死循环(<2s返回)", dur < 2 and 1 <= len(chain11) <= 3, (dur, len(chain11)))
# ---------- T7pre_prompt_compaction 事件 payload 端到端 ----------
model = ModelConfig(provider="t", name="t", context_window=500,
max_tokens=100, api_key="k", base_url="http://x")
cfg = AgentConfig(model=model, system_prompt="s", tools=[], tool_context={},
compaction_reserve=100, compaction_keep_recent=100)
from core.agent.agent import Agent # noqa: E402
agent = Agent(cfg)
old_msgs = [
AgentMessage(role="user", content="旧旧问题", db_msg_id="m1"),
AgentMessage(role="assistant", content="旧旧回答", db_msg_id="m2"),
AgentMessage(role="user", content="旧上下文 " * 200, db_msg_id="m3"),
AgentMessage(role="assistant", content="新回答", db_msg_id="m4"),
AgentMessage(role="user", content="新问题", db_msg_id="m5"),
]
agent.state.messages = old_msgs
runner = AgentRunner(agent, summarize_fn=lambda p, s, mt: "摘要X")
ok = runner.pre_prompt_compaction()
check("T7.压缩被触发", ok is True)
ev = runner.compaction_events[-1]
new_msgs = agent.state.messages
first = new_msgs[1]
idx = next(i for i, m in enumerate(old_msgs) if m is first)
check("T7.payload cut_before_id=切点前DB行",
ev.get("cut_before_id") == old_msgs[idx - 1].db_msg_id, ev)
check("T7.payload first_retained_id=尾巴首条DB行",
ev.get("first_retained_id") == first.db_msg_id, ev)
check("T7.新消息=摘要+尾巴", new_msgs[0].kind == "compaction_summary"
and new_msgs[1:] == old_msgs[idx:])
# ---------- T8_key 不泄漏到 API ----------
api_out = to_openai_messages(old_msgs)
check("T8.下划线内部字段不进API",
all(not any(k.startswith("_") for k in m.keys()) for m in api_out))
# ---------- T9UI 渲染过滤排除 compaction(静态) ----------
_mw_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"ui", "views", "main_window.py")
with open(_mw_path, "r", encoding="utf-8") as f:
_src = f.read()
check("T9.渲染过滤含 compaction", 'msg["role"] not in ("system", "compaction")' in _src)
# ---------- T12Fix A — 摘要条目 timestamp → P0 失效过期锚点 ----------
# 场景(长程会话 92.7k→11k):保留行里 assistant 的入库 usage 是压缩前快照;
# 若摘要条目不带 timestamp,显示/压缩判定会锚到过期值(92.7k),而真实
# 下一轮输入只有摘要+保留行(~11k)。
sid3 = db.create_session("过期锚点失效")["id"]
u1 = db.add_message(session_id=sid3, role="user", content="u" * 2000, parent_id=None)
a1 = db.add_message(session_id=sid3, role="assistant", content="A" * 40000,
parent_id=u1["id"],
usage=json.dumps({"input": 92700, "output": 500,
"cacheRead": 0, "cacheWrite": 0}))
u2 = db.add_message(session_id=sid3, role="user", content="v" * 2000, parent_id=a1["id"])
db.add_message(session_id=sid3, role="assistant", content="B" * 40000, parent_id=u2["id"],
usage=json.dumps({"input": 92700, "output": 500,
"cacheRead": 0, "cacheWrite": 0}))
# 把全部行时间戳拨到 3000s 前(模拟它们早于压缩发生)
with db.get_connection() as conn:
conn.execute("UPDATE messages SET created_at=? WHERE session_id=?",
(int(_t.time()) - 3000, sid3))
conn.commit()
api_ns = MainWindow.build_api_context(_Dummy(), sid3)
est_ns = estimate_context_tokens(from_openai_messages(api_ns))
check("T12.对照:无标记→锚定入库usage", est_ns.usage_tokens > 0, est_ns.usage_tokens)
mid3 = db.insert_compaction_mark(sid3, "摘要", a1["id"], u2["id"])
api3 = MainWindow.build_api_context(_Dummy(), sid3)
check("T12.摘要条目带timestamp且晚于保留行",
api3 and api3[0].get("timestamp", 0) > 0
and all(api3[0]["timestamp"] > m.get("timestamp", 0) for m in api3[1:]))
est3 = estimate_context_tokens(from_openai_messages(api3))
check("T12.有标记→过期锚点失效(usage_tokens=0)", est3.usage_tokens == 0,
est3.usage_tokens)
check("T12.估算=全量公式(远离过期93.2k)", est3.tokens < 40000, est3.tokens)
# ---------- T13Fix B + G1 — 工具行显示不锚点(G1 行块规则单元化) ----------
# 场景(任务流会话 40.5k→80k):工具行的入库 usage 是本轮最后一个子请求的
# 快照(工具输出未截断),下一轮真实输入=4k 截断回放。🆕 G1 后不再需要
# 手动清锚:锚点选择器自动失效含工具行的 usage → 估算=全量公式。
sid4 = db.create_session("工具行不锚点")["id"]
p1 = db.add_message(session_id=sid4, role="user", content="prompt", parent_id=None)
tl = json.dumps([
{"t": "text", "text": "code" * 50000},
{"t": "tool", "id": "c1", "name": "bash", "args": "{}", "result": "ok"},
{"t": "text", "text": "done"},
], ensure_ascii=False)
db.add_message(session_id=sid4, role="assistant", content="x", parent_id=p1["id"],
timeline=tl,
usage=json.dumps({"input": 32517, "output": 7999,
"cacheRead": 0, "cacheWrite": 0}))
api4 = MainWindow.build_api_context(_Dummy(), sid4)
check("T13.回放含tool条目(规则条件成立)",
any(m.get("role") == "tool" for m in api4))
_est4 = estimate_context_tokens(from_openai_messages(api4))
check("T13.G1:工具行入库usage自动失效锚点(无需手动清锚)",
_est4.usage_tokens == 0, _est4.usage_tokens)
check("T13.估算=全量公式(>旧锚点40516,度量截断回放)",
_est4.tokens > 40516, _est4.tokens)
# G1 精确性:仅失效「含工具活动的行」的 usage,其后的纯文本行锚点保留
sid4b = db.create_session("工具行后纯文本行锚点保留")["id"]
p1b = db.add_message(session_id=sid4b, role="user", content="prompt", parent_id=None)
t1b = db.add_message(session_id=sid4b, role="assistant", content="x", parent_id=p1b["id"],
timeline=tl,
usage=json.dumps({"input": 32517, "output": 7999,
"cacheRead": 0, "cacheWrite": 0}))
u1b = db.add_message(session_id=sid4b, role="user", content="q2", parent_id=t1b["id"])
_a2b = db.add_message(session_id=sid4b, role="assistant", content="y", parent_id=u1b["id"],
usage=json.dumps({"input": 90000, "output": 100,
"cacheRead": 0, "cacheWrite": 0}))
api4b = MainWindow.build_api_context(_Dummy(), sid4b)
_est4b = estimate_context_tokens(from_openai_messages(api4b))
check("T13.G1:工具行(旧)usage失效、其后纯文本行(新)usage保留锚点",
_est4b.usage_tokens > 0 and _est4b.usage_tokens == 90100,
_est4b.usage_tokens)
# 纯文本会话:规则条件不成立 → 保留精确锚点(provider 实测值更准)
sid5 = db.create_session("文本行锚点")["id"]
p2 = db.add_message(session_id=sid5, role="user", content="hi", parent_id=None)
db.add_message(session_id=sid5, role="assistant", content="hello", parent_id=p2["id"],
usage=json.dumps({"input": 100, "output": 50,
"cacheRead": 0, "cacheWrite": 0}))
api5 = MainWindow.build_api_context(_Dummy(), sid5)
check("T13.纯文本行→无tool条目(保留锚点)",
not any(m.get("role") == "tool" for m in api5)
and estimate_context_tokens(from_openai_messages(api5)).usage_tokens > 0)
# ---------- T14G2 — should_compact 透传 system/tools(无锚点分支度量下一请求) ----------
from core.agent.context import should_compact
_mc14 = ModelConfig(name="t", context_window=100000)
_msgs14 = from_openai_messages(api4) # 工具行 → G1 无锚点
_sh_no, _tok_no = should_compact(_msgs14, _mc14, 16384)
_sh_yes, _tok_yes = should_compact(_msgs14, _mc14, 16384,
system_prompt="S" * 2000,
tools=[types.SimpleNamespace(
name="bash", description="d" * 1000,
parameters={})])
check("T14.传入system/tools后估算增大(无锚点分支)",
_tok_yes > _tok_no, (_tok_no, _tok_yes))
# 阈值边缘:同样内容,带上 system/tools 后跨过阈值 → 触发判定变化
_mc_edge = ModelConfig(name="t2", context_window=_tok_no + 16384 + 5)
_sh_edge_no, _ = should_compact(_msgs14, _mc_edge, 16384)
_sh_edge_yes, _ = should_compact(_msgs14, _mc_edge, 16384,
system_prompt="S" * 2000,
tools=[types.SimpleNamespace(
name="bash", description="d" * 1000,
parameters={})])
check("T14.阈值边缘:计入system/tools才触发(防低估漏压缩)",
_sh_edge_no is False and _sh_edge_yes is True,
(_sh_edge_no, _sh_edge_yes))
failed = [n for n, okk in RESULTS if not okk]
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
sys.exit(0 if not failed else 1)
+336
View File
@@ -0,0 +1,336 @@
# -*- coding: utf-8 -*-
"""
会话复制DBManager.copy_session单测
全程临时 DB + 临时附件目录绝不触碰真实 data/chat_history.db
覆盖
T1 线性会话复制消息数/顺序/内容一致ID 全新
T2 parent_id 链重建正确首条 parent None链走通
T3 分叉会话整树复制分支数一致
T4 压缩标记原样复制数量/内容/切点关系/时间戳晚于全部保留行
T5 current_leaf_msg_id 重映射且真实存在
T6 源会话零改动全列快照比对
T7 删副本 源完好删源 副本完好
T8 图片附件物理复制到新路径新旧文件同时存在内容相同
T9 文本附件原样保留不产生新文件metadata 逐字节相同
T10 title / is_starred / mode / has_messages / sort_order 语义
T11 不存在的 session_id None且无残留
T12 副本再复制 标题 (副本 2)
T13 外键无违规 + 无孤儿 parent_id
T14 get_message_chain() 副本 (role, content, is_ignored) 序列完全相同
运行: PYTHONIOENCODING=utf-8 python tests/test_copy_session.py
"""
import os
import sys
import json
import uuid
import time
import tempfile
import shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.db_manager import DBManager # noqa: E402
RESULTS = []
def check(name, cond, extra=""):
RESULTS.append((name, bool(cond)))
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
_TMP = tempfile.mkdtemp(prefix="hocode_copysess_")
_ATT = os.path.join(_TMP, "data", "attachments")
os.makedirs(_ATT, exist_ok=True)
db = DBManager(os.path.join(_TMP, "data", "chat_history.db"))
db.files_root = _TMP # 附件根指向临时目录
# ======================================================================
# 工具函数
# ======================================================================
def mk_session(title, msgs, mode=None, starred=0, has_messages=1, att_meta=None):
"""建一个线性会话。msgs=[(role, content)]att_meta={index: json_string}
返回 (session_id, [msg_id])"""
sid = "sess_" + uuid.uuid4().hex[:12]
now = int(time.time())
with db.get_connection() as conn:
cur = conn.cursor()
cur.execute(
"INSERT INTO sessions (id,title,created_at,updated_at,has_messages,sort_order,"
"is_starred,current_leaf_msg_id,mode) VALUES (?,?,?,?,?,?,?,?,?)",
(sid, title, now, now, has_messages, db.get_min_sort_order(), starred, None, mode))
prev, ids = None, []
for i, (role, content) in enumerate(msgs):
mid = ("comp_" if role == "compaction" else "msg_") + uuid.uuid4().hex[:16]
meta = (att_meta or {}).get(i)
cur.execute(
"INSERT INTO messages (id,session_id,role,content,reasoning,is_ignored,"
"created_at,attachment_metadata,parent_id,timeline,usage) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(mid, sid, role, content, "思考内容", 0, now - 1000 + i, meta, prev,
'{"t":1}', '{"input":10,"output":5}'))
ids.append(mid)
prev = mid
cur.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev, sid))
conn.commit()
return sid, ids
def add_child(session_id, parent_id, role, content, ts):
"""在指定 parent 下挂一个子消息(构造分叉用)"""
mid = "msg_" + uuid.uuid4().hex[:16]
with db.get_connection() as conn:
conn.execute(
"INSERT INTO messages (id,session_id,role,content,reasoning,is_ignored,"
"created_at,attachment_metadata,parent_id,timeline,usage) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(mid, session_id, role, content, "", 0, ts, None, parent_id, None, None))
conn.commit()
return mid
def rows_of(sid):
with db.get_connection() as conn:
return [dict(r) for r in conn.execute(
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, rowid ASC",
(sid,)).fetchall()]
def sess_of(sid):
with db.get_connection() as conn:
r = conn.execute("SELECT * FROM sessions WHERE id = ?", (sid,)).fetchone()
return dict(r) if r else None
def sess_count():
with db.get_connection() as conn:
return conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
def chain_sig(sid):
"""(role, content, is_ignored) 序列"""
return [(m["role"], m["content"], m["is_ignored"])
for m in db.get_message_chain(sid)]
# ======================================================================
try:
# ---------------- T1/T2: 线性会话 ----------------
src, src_ids = mk_session("线性会话", [
("system", "sys"), ("user", "u1"), ("assistant", "a1"),
("user", "u2"), ("assistant", "a2")])
n_before = sess_count()
cpy = db.copy_session(src)
check("T1.1 返回新会话 dict 且 id 不同", cpy and cpy["id"] != src)
check("T1.2 会话数 +1", sess_count() == n_before + 1, f"{n_before}->{sess_count()}")
s_rows, c_rows = rows_of(src), rows_of(cpy["id"])
check("T1.3 消息数一致", len(s_rows) == len(c_rows) == 5, f"{len(s_rows)}/{len(c_rows)}")
check("T1.4 ID 全新无交集",
not (set(r["id"] for r in s_rows) & set(r["id"] for r in c_rows)))
check("T1.5 role+content 序列一致",
[(r["role"], r["content"]) for r in s_rows] ==
[(r["role"], r["content"]) for r in c_rows])
check("T1.6 reasoning/timeline/usage 原样复制",
all(s["reasoning"] == c["reasoning"] and s["timeline"] == c["timeline"]
and s["usage"] == c["usage"] for s, c in zip(s_rows, c_rows)))
# T2: parent 链重建
idmap = {s["id"]: c["id"] for s, c in zip(s_rows, c_rows)}
ok_chain = c_rows[0]["parent_id"] is None
for s, c in list(zip(s_rows, c_rows))[1:]:
ok_chain = ok_chain and c["parent_id"] == idmap.get(s["parent_id"])
check("T2.1 parent_id 全量重映射", ok_chain)
check("T2.2 副本链首 parent 为 None(未指回源 ID", c_rows[0]["parent_id"] is None)
check("T2.3 副本链里不含任何源 ID",
not (set(idmap.keys()) & set(r["parent_id"] for r in c_rows if r["parent_id"])))
# ---------------- T3: 分叉会话整树复制 ----------------
sid3, ids3 = mk_session("分叉会话", [("system", "s"), ("user", "q")])
base = ids3[-1]
add_child(sid3, base, "assistant", "br1", int(time.time()) + 10)
add_child(sid3, base, "assistant", "br2", int(time.time()) + 11)
copy3 = db.copy_session(sid3)
c3 = rows_of(copy3["id"])
src_branches = len(db.get_branch_info(base))
c_base = [r for r in c3 if r["role"] == "user"][0]["id"]
check("T3.1 源有 2 个分支", src_branches == 2, str(src_branches))
check("T3.2 副本分支数一致", len(db.get_branch_info(c_base)) == 2,
str(len(db.get_branch_info(c_base))))
check("T3.3 副本消息总数一致", len(c3) == len(rows_of(sid3)) == 4)
check("T3.4 分支内容一致",
sorted(b["content"] for b in db.get_branch_info(c_base)) == ["br1", "br2"])
# ---------------- T4: 压缩标记 ----------------
sid4, ids4 = mk_session("压缩会话", [
("system", "s"), ("user", "u1"), ("assistant", "a1"),
("user", "u2"), ("assistant", "a2")])
mark = db.insert_compaction_mark(sid4, "【摘要】前面聊了 u1/a1",
cut_before_id=ids4[2], first_retained_id=ids4[3])
copy4 = db.copy_session(sid4)
c4 = rows_of(copy4["id"])
s4 = rows_of(sid4)
c_marks = [r for r in c4 if r["role"] == "compaction"]
s_marks = [r for r in s4 if r["role"] == "compaction"]
check("T4.1 标记存在且已复制", len(s_marks) == 1 and len(c_marks) == 1)
check("T4.2 摘要内容一致", c_marks and c_marks[0]["content"] == s_marks[0]["content"])
m4 = {s["id"]: c["id"] for s, c in zip(s4, c4)}
check("T4.3 标记 parent 指向副本的 cut_before",
c_marks[0]["parent_id"] == m4.get(ids4[2]))
check("T4.4 first_retained 的 parent 指向副本标记",
[r for r in c4 if r["id"] == m4.get(ids4[3])][0]["parent_id"] == c_marks[0]["id"])
check("T4.5 标记 id 带 comp_ 前缀", c_marks[0]["id"].startswith("comp_"))
retained = [r for r in c4 if r["created_at"] is not None
and r["id"] != c_marks[0]["id"]]
check("T4.6 标记时间戳晚于全部其它行",
all(c_marks[0]["created_at"] >= r["created_at"] for r in retained))
check("T4.7 标记 is_ignored 保持 1", c_marks[0]["is_ignored"] == 1)
# created_at 相对次序必须与源完全一致(兄弟排序依赖它)
s_order = [i for i, _ in sorted(enumerate([r["created_at"] for r in s4]),
key=lambda x: x[1])]
c_order = [i for i, _ in sorted(enumerate([r["created_at"] for r in c4]),
key=lambda x: x[1])]
check("T4.8 created_at 相对次序与源一致", s_order == c_order, f"{s_order} vs {c_order}")
# ---------------- T5: 叶子重映射 ----------------
check("T5.1 副本叶子非空且不是源叶子",
cpy["current_leaf_msg_id"] and cpy["current_leaf_msg_id"] != sess_of(src)["current_leaf_msg_id"])
check("T5.2 副本叶子真实存在于副本",
cpy["current_leaf_msg_id"] in [r["id"] for r in c_rows])
check("T5.3 副本叶子对应源叶子的内容",
[r for r in c_rows if r["id"] == cpy["current_leaf_msg_id"]][0]["content"]
== s_rows[-1]["content"])
# ---------------- T6: 源会话零改动 ----------------
src_snapshot = (sess_of(src), [tuple(sorted(r.items())) for r in rows_of(src)])
_ = db.copy_session(src)
src_after = (sess_of(src), [tuple(sorted(r.items())) for r in rows_of(src)])
check("T6.1 复制两次后源 sessions 行不变", src_snapshot[0] == src_after[0])
check("T6.2 复制两次后源 messages 全列不变", src_snapshot[1] == src_after[1])
# ---------------- T7: 双向删除隔离 ----------------
sid7, _ = mk_session("隔离A", [("system", "s"), ("user", "x")])
c7 = db.copy_session(sid7)
db.delete_session(c7["id"])
check("T7.1 删副本后源仍在且消息完整",
sess_of(sid7) is not None and len(rows_of(sid7)) == 2)
# 方向二另起一对,避免与方向一互相干扰
sid7b, _ = mk_session("隔离B", [("system", "s"), ("user", "y")])
c7b = db.copy_session(sid7b)
db.delete_session(sid7b)
check("T7.2 删源后副本仍在且消息完整",
sess_of(c7b["id"]) is not None and len(rows_of(c7b["id"])) == 2,
f'cpy sess={sess_of(c7b["id"]) is not None} rows={len(rows_of(c7b["id"]))}')
# ---------------- T8: 图片附件物理复制 ----------------
png_src = os.path.join(_ATT, "img_src_test.png")
with open(png_src, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n" + b"FAKEIMAGEDATA" * 8)
img_meta = json.dumps({"user_text": "看看这张图",
"attachments": [{"type": "image", "size_kb": 0.2,
"local_path": "data/attachments/img_src_test.png"}]},
ensure_ascii=False)
sid8, _ = mk_session("图片会话", [("system", "s"), ("user", "带图")],
att_meta={1: img_meta})
c8 = db.copy_session(sid8)
m8 = [r for r in rows_of(c8["id"]) if r["attachment_metadata"]][0]
meta8 = json.loads(m8["attachment_metadata"])
new_rel = meta8["attachments"][0]["local_path"]
new_abs = os.path.join(_TMP, new_rel)
check("T8.1 local_path 已改写为新文件",
new_rel != "data/attachments/img_src_test.png", new_rel)
check("T8.2 新旧文件同时存在",
os.path.isfile(png_src) and os.path.isfile(new_abs))
check("T8.3 新文件内容与源一致",
open(new_abs, "rb").read() == open(png_src, "rb").read())
check("T8.4 user_text 等其它字段保留", meta8["user_text"] == "看看这张图")
check("T8.5 源 metadata 未被改动",
json.loads([r for r in rows_of(sid8) if r["attachment_metadata"]][0]
["attachment_metadata"])["attachments"][0]["local_path"]
== "data/attachments/img_src_test.png")
# 缺文件容错:metadata 保留原路径,不抛异常
bad_meta = json.dumps({"user_text": "x", "attachments": [
{"type": "image", "local_path": "data/attachments/does_not_exist.png"}]},
ensure_ascii=False)
sid8b, _ = mk_session("缺文件", [("system", "s"), ("user", "y")], att_meta={1: bad_meta})
c8b = db.copy_session(sid8b)
bad_after = [r for r in rows_of(c8b["id"]) if r["attachment_metadata"]][0]
check("T8.6 源文件缺失时保留原路径且不失败",
json.loads(bad_after["attachment_metadata"])["attachments"][0]["local_path"]
== "data/attachments/does_not_exist.png")
# ---------------- T9: 文本附件原样 ----------------
txt_meta = json.dumps({"user_text": "", "attachments": [
{"type": "text", "size_kb": 1.0, "lines": 3, "content": "aaa\nbbb\nccc"}]},
ensure_ascii=False)
sid9, _ = mk_session("文本附件", [("system", "s"), ("user", "t")], att_meta={1: txt_meta})
n_files_before = len(os.listdir(_ATT))
c9 = db.copy_session(sid9)
t9 = [r for r in rows_of(c9["id"]) if r["attachment_metadata"]][0]
check("T9.1 文本附件 metadata 逐字节相同",
t9["attachment_metadata"] == txt_meta, t9["attachment_metadata"][:60])
check("T9.2 文本附件不产生新文件", len(os.listdir(_ATT)) == n_files_before)
# ---------------- T10: 字段语义 ----------------
sid10, _ = mk_session("语义检查", [("system", "s"), ("user", "m")],
mode="worker", starred=1)
min_order_before = db.get_min_sort_order()
c10 = db.copy_session(sid10)
check("T10.1 标题 = 原名 + ' (副本)'", c10["title"] == "语义检查 (副本)", c10["title"])
check("T10.2 副本不带星标", c10["is_starred"] == 0, str(c10["is_starred"]))
check("T10.3 mode 跟随源", c10["mode"] == "worker", str(c10["mode"]))
check("T10.4 has_messages 跟随源", c10["has_messages"] == sess_of(sid10)["has_messages"])
check("T10.5 sort_order 置顶", c10["sort_order"] == min_order_before,
f'{c10["sort_order"]} vs {min_order_before}')
check("T10.6 源仍是星标且未被改标题",
sess_of(sid10)["is_starred"] == 1 and sess_of(sid10)["title"] == "语义检查")
check("T10.7 created_at/updated_at = 当前时间",
abs(c10["updated_at"] - int(time.time())) <= 5)
# 无消息的会话(只有 system 行)也能复制
sid10b, ids10b = mk_session("孤儿", [("system", "only")], has_messages=0)
c10b = db.copy_session(sid10b)
check("T10.8 仅 system 行的会话可复制",
c10b and len(rows_of(c10b["id"])) == 1 and c10b["current_leaf_msg_id"] is not None)
# ---------------- T12: 副本再复制 → (副本 2) ----------------
c12 = db.copy_session(c10["id"])
check("T12.1 再复制标题递增为 (副本 2)", c12["title"] == "语义检查 (副本 2)", c12["title"])
c12b = db.copy_session(c12["id"])
check("T12.2 第三次复制为 (副本 3)", c12b["title"] == "语义检查 (副本 3)", c12b["title"])
c12c = db.copy_session(sid10, new_title="自定义标题")
check("T12.3 可显式指定标题", c12c["title"] == "自定义标题", c12c["title"])
# ---------------- T11: 不存在的会话 ----------------
n11 = sess_count()
check("T11.1 不存在 → None", db.copy_session("sess_not_exist_xxx") is None)
check("T11.2 无残留(会话数不变)", sess_count() == n11)
# ---------------- T13: 外键 / 孤儿 ----------------
bad_fk = 0
orphans = 0
with db.get_connection() as conn:
bad_fk = len(conn.execute("PRAGMA foreign_key_check").fetchall())
for r in rows_of(cpy["id"]):
if r["parent_id"] and r["parent_id"] not in [x["id"] for x in c_rows]:
orphans += 1
check("T13.1 外键无违规", bad_fk == 0, str(bad_fk))
check("T13.2 无孤儿 parent_id", orphans == 0, str(orphans))
# ---------------- T14: 链签名一致(端到端) ----------------
check("T14.1 线性会话链签名一致", chain_sig(src) == chain_sig(cpy["id"]))
check("T14.2 分叉会话链签名一致", chain_sig(sid3) == chain_sig(copy3["id"]))
check("T14.3 压缩会话链签名一致(含标记行)", chain_sig(sid4) == chain_sig(copy4["id"]))
finally:
shutil.rmtree(_TMP, ignore_errors=True)
failed = [n for n, ok in RESULTS if not ok]
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
sys.exit(0 if not failed else 1)
+139
View File
@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""独立调试器窗口 + 调试日志协议 回归测试(离屏,临时文件,不碰真实 DB/日志)
运行: PYTHONIOENCODING=utf-8 QT_QPA_PLATFORM=offscreen python tests/test_debug_window.py
"""
import sys, os, re, tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
_TMPDIR = tempfile.mkdtemp(prefix="haocode_dbg_")
os.environ["HAOCODE_DEBUG_LOG"] = os.path.join(_TMPDIR, "debug_session.log")
os.environ["HAOCODE_DEBUG_CMD"] = os.path.join(_TMPDIR, "debug_window.cmd")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
from core import debug_log as dl
PASS, FAIL = 0, 0
def check(name, cond, extra=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {extra}")
def read_log():
if not os.path.exists(dl.DEBUG_LOG_PATH):
return ""
with open(dl.DEBUG_LOG_PATH, "r", encoding="utf-8") as f:
return f.read()
LINE_RE = re.compile(r"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] "
r"\[(USER|AGENT|APP|SYS)\] .+$")
# ============ T1: debug_log 三方写入 + 行格式 ============
print("T1: debug_log 行格式与三方 TAG")
dl.debug_log("hello app", "APP")
dl.debug_log("代理注入一条备注", "AGENT")
dl.debug_log("用户观察到标签 40.5k", "USER")
lines = [l for l in read_log().splitlines() if l]
check("三行全部写入", len(lines) == 3, lines)
check("行格式 [ts] [TAG] msg",
all(LINE_RE.match(l) for l in lines), lines)
check("TAG 顺序 APP/AGENT/USER",
[re.search(r"\[(USER|AGENT|APP|SYS)\]", l).group(1) for l in lines]
== ["APP", "AGENT", "USER"])
# ============ T2: poll_debug_cmd 控制协议 ============
print("T2: poll_debug_cmd 消费语义")
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write("show\n")
check("show 被识别", dl.poll_debug_cmd() == "show")
check("文件被消费(再读为 None", dl.poll_debug_cmd() is None)
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write(" HIDE ")
check("hide 大小写/空白容忍", dl.poll_debug_cmd() == "hide")
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write("garbage")
check("非法内容忽略", dl.poll_debug_cmd() is None)
# ============ T3-T6: DebugWindow 行为(离屏) ============
print("T3: DebugWindow 用户输入 → [USER] 落盘")
from PyQt6.QtWidgets import QApplication
app = QApplication.instance() or QApplication(sys.argv)
import ui.views.debug_window as dw
# 应用日志 tab 指向临时文件(不读真实 diag.log)
_tmp_diag = os.path.join(_TMPDIR, "diag.log")
dw._APP_LOGS[:] = [("DIAG", _tmp_diag)]
win = dw.DebugWindow()
check("窗口标题独立", win.windowTitle() == "Haocode 调试器")
win._input.setText(" 发送后标签跳到 80k ")
win._on_submit()
check("输入框被清空", win._input.text() == "")
check("[USER] 已落盘且去首尾空白",
any(l.endswith("[USER] 发送后标签跳到 80k") for l in read_log().splitlines()))
print("T4: 首次 tick 记 [SYS] 开启事件")
win._tick()
check("[SYS] 调试窗口开启 已写入",
any("[SYS] 调试窗口开启" in l for l in read_log().splitlines()))
print("T5: 实时 tail 会话日志 + 应用日志")
dl.debug_log("tick 前注入的 APP 事件", "APP")
dl.debug_log("AGENT: 现在检查 compaction_diag.log", "AGENT")
win._tick()
txt = win._view_session.toPlainText()
check("会话视图含 APP 事件", "tick 前注入的 APP 事件" in txt, txt[-300:])
check("会话视图含 AGENT 注入", "AGENT: 现在检查 compaction_diag.log" in txt)
with open(_tmp_diag, "w", encoding="utf-8") as f:
f.write("[12:00:00.000] DIAG_TEST_LINE\n")
win._tick()
check("应用日志视图含 DIAG tail",
"DIAG_TEST_LINE" in win._view_app.toPlainText())
check("应用日志带文件头", "[DIAG]" in win._view_app.toPlainText())
print("T6: 暂停显示 + 截断重置")
win._chk_pause.setChecked(True) # 暂停
dl.debug_log("暂停期间的行不应上屏", "APP")
win._tick()
check("暂停期间不上屏", "暂停期间的行不应上屏"
not in win._view_session.toPlainText())
# 文件截断(模拟「清空会话日志」)→ 偏移重置,新行仍可读取
win._chk_pause.setChecked(False)
with open(dl.DEBUG_LOG_PATH, "w", encoding="utf-8") as f:
f.write("[2026-01-01 00:00:00.000] [SYS] 截断后新内容\n")
win._tick()
check("截断后偏移重置、新行上屏",
"截断后新内容" in win._view_session.toPlainText())
win.close()
# ============ T4: 调试窗口随程序启动(autostart_debug_window ============
print("T4: 调试窗口随程序启动")
p = dl.DEBUG_CMD_PATH
if os.path.exists(p):
os.remove(p)
# 显式 false → 不写控制文件
check("T4.autostart=False 不写控制文件",
dl.autostart_debug_window({"debug_window_autostart": False}) is False
and not os.path.exists(p))
# 缺省(键不存在)→ 默认开
check("T4.缺省(无键)写入 show",
dl.autostart_debug_window({}) is True and os.path.exists(p))
check("T4.轮询消费 show",
dl.poll_debug_cmd() == "show" and not os.path.exists(p))
# 显式 true
check("T4.autostart=true 写入 show",
dl.autostart_debug_window({"debug_window_autostart": True}) is True)
check("T4.轮询再消费 show",
dl.poll_debug_cmd() == "show")
print(f"\n===== {PASS} PASS / {FAIL} FAIL =====")
sys.exit(1 if FAIL else 0)
+368
View File
@@ -0,0 +1,368 @@
# -*- coding: utf-8 -*-
"""失败轮次持久化(对照 pi:message_end 无条件入库 → 出错也写会话)+ 回放取舍。
核心诉求agent 跑了 200 个工具后第 201 步出错**已完成的工具结果不能白跑**
必须入库必须能进下次上下文这样"接着执行最后一次"才可能
覆盖矩阵
T1 全空失败轮 入库留痕(is_ignored=1)回放**跳过**
T2 有工具/正文的失败轮 入库 + 回放工具调用与结果成对核心
T3 轮内重试 只动内存**不入库**不会产生一堆错误行
T4 孤儿工具(ok=None) 回放注入合成结果对照 pi insertSyntheticToolResults
T5 叶子前进 不再"时光倒流"parent_id 指向本轮提问
T6 压缩切点交互 切点之后照常回放之前 出上下文
T7 旧库自动迁移 新列补齐旧行 stop_reason=NULL 行为不变
T8 copy_session 复制错误行时携带 stop_reason/error_message/is_ignored
运行: QT_QPA_PLATFORM=offscreen python tests/test_error_persist.py
"""
import os
import sys
import json
import uuid
import sqlite3
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# 铁律:测试不得污染真实 data/chat_history.db
import core.db_manager as _dbm # noqa: E402
_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_errpersist_{os.getpid()}.db")
if os.path.exists(_DB_TMP):
os.remove(_DB_TMP)
_dbm._DEFAULT_DB = _DB_TMP
# 铁律:不得污染真实 data/config.json
_CFG_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_errcfg_{os.getpid()}.json")
with open(_CFG_TMP, "w", encoding="utf-8") as _f:
_f.write('{"providers": {}}')
os.environ["HAOCODE_CONFIG_FILE"] = _CFG_TMP
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
from core.db_manager import DBManager # noqa: E402
from core.agent.recovery import AgentRunner # noqa: E402
from core.agent.types import (AgentConfig, AgentMessage, ModelConfig, # noqa: E402
RetryConfig)
app = QApplication(sys.argv)
ok = True
def check(name, cond, extra=""):
global ok
print((" PASS " if cond else " FAIL ") + name +
("" if cond else f" {extra}"), flush=True)
if not cond:
ok = False
def settle(ms=120):
for _ in range(int(ms / 20) + 1):
app.processEvents()
QTest.qWait(20)
window = MainWindow()
window.show()
settle(300)
def mk_failed_turn(sid, body="", reasoning="", tools=None, err="连接失败: Connection error"):
"""模拟一次「已流出一部分 → 出错」的轮次,返回 (user_msg_id, stream_state)"""
chain = window.db.get_message_chain(sid)
parent = chain[-1]["id"] if chain else None
st = {
"msg_id": f"msg_err_{uuid.uuid4().hex[:10]}",
"parent_id": parent,
"previous_leaf_id": parent,
"content": body,
"reasoning": reasoning,
"timeline": list(tools or []),
"usage": {},
"tl_kind": "text",
"worker": None,
}
window.current_session_id = sid
window._active_streams[sid] = st
window.on_error(sid, err)
settle(120)
return parent, st
def tool_entry(cid, name, args, ok_flag, result):
return {"t": "tool", "id": cid, "name": name, "args": args,
"ok": ok_flag, "result": result}
# ======================================================================
# T1 全空失败轮 → 入库留痕但回放跳过
# ======================================================================
sid1 = window.db.create_session("T1 全空失败")["id"]
uid1 = window.db.add_message(session_id=sid1, role="user", content="开始吧",
parent_id=None)["id"]
mk_failed_turn(sid1, body="", reasoning="", tools=[])
chain1 = window.db.get_message_chain(sid1)
last1 = chain1[-1]
check("T1.1 全空失败轮也入库(对照 pi 无条件 append)",
len(chain1) == 2 and last1["role"] == "assistant", f"chain={len(chain1)}")
check("T1.2 标记 stop_reason=error + error_message",
last1.get("stop_reason") == "error" and "Connection error" in (last1.get("error_message") or ""),
f"{last1.get('stop_reason')} / {last1.get('error_message')}")
check("T1.3 全空 → is_ignored=1(只在库里留痕)", int(last1.get("is_ignored") or 0) == 1)
check("T1.4 正文含 ⚠️ 中断说明(供历史/UI 可见)",
"⚠️" in (last1.get("content") or "") and "本轮中断" in (last1.get("content") or ""),
(last1.get("content") or "")[:60])
ctx1 = window.build_api_context(sid1)
check("T1.5 回放**跳过**全空错误行(避免空 assistant 触发服务商 400",
not any(m.get("role") == "assistant" for m in ctx1),
str([m.get("role") for m in ctx1]))
# ======================================================================
# T2 ★核心:200 个工具后第 201 步出错 → 工具结果必须进上下文
# ======================================================================
sid2 = window.db.create_session("T2 工具后出错")["id"]
uid2 = window.db.add_message(session_id=sid2, role="user",
content="帮我建 3 个目录", parent_id=None)["id"]
tools2 = [tool_entry(f"c{i}", "bash", json.dumps({"command": f"mkdir d{i}"}),
True, f"d{i} created") for i in range(1, 4)]
tools2.append(tool_entry("c4", "bash", json.dumps({"command": "mkdir d4"}),
None, "")) # 孤儿:开始了但没结果
mk_failed_turn(sid2, body="正在创建目录…", reasoning="先建目录", tools=tools2)
chain2 = window.db.get_message_chain(sid2)
last2 = chain2[-1]
check("T2.1 有工具/正文的失败轮入库且 is_ignored=0(会回放)",
last2["role"] == "assistant" and int(last2.get("is_ignored") or 0) == 0,
f"ignored={last2.get('is_ignored')}")
check("T2.2 timeline 完整保留 4 个工具条目",
len([e for e in json.loads(last2["timeline"] or "[]")
if e.get("t") == "tool"]) == 4,
str([e.get("t") for e in json.loads(last2["timeline"] or "[]")]))
check("T2.2b timeline 尾部多一条 text 条目(中断说明,回放时才看得到)",
(json.loads(last2["timeline"] or "[]")[-1].get("t") == "text"
and "⚠️" in json.loads(last2["timeline"] or "[]")[-1].get("text", "")),
str(json.loads(last2["timeline"] or "[]")[-1])[:80])
check("T2.3 正文保留 + 尾部中断说明",
"正在创建目录" in last2["content"] and "⚠️" in last2["content"])
ctx2 = window.build_api_context(sid2)
tcs = [tc for m in ctx2 if m.get("role") == "assistant" for tc in (m.get("tool_calls") or [])]
tool_msgs = [m for m in ctx2 if m.get("role") == "tool"]
check("T2.4 ★回放里出现 4 个 tool_call(200 个工具的记录没丢)", len(tcs) == 4,
f"tool_calls={len(tcs)}")
check("T2.5 ★每个 tool_call 都有配对的 tool 结果(成对,服务商不会 400)",
len(tool_msgs) == len(tcs) and
{m["tool_call_id"] for m in tool_msgs} == {t["id"] for t in tcs},
f"tool_msgs={len(tool_msgs)}")
check("T2.6 已完成的工具结果原文进上下文",
any("d1 created" in (m.get("content") or "") for m in tool_msgs))
check("T2.7 错误说明文本也进上下文(模型知道上轮为何断)",
any(m.get("role") == "assistant" and "⚠️" in (m.get("content") or "")
for m in ctx2))
check("T2.8 顺序正确:assistant tool_calls 在 tool 结果之前",
[m.get("role") for m in ctx2].index("assistant") <
[m.get("role") for m in ctx2].index("tool"))
# ======================================================================
# T3 轮内重试只动内存、不入库
# ======================================================================
class _FakeState:
def __init__(self, msgs):
self.messages = msgs
class _FakeAgent:
def __init__(self):
self.config = AgentConfig(
model=ModelConfig(name="fake"),
retry=RetryConfig(max_attempts=3, base_delay_ms=1, factor=2.0),
)
self.state = _FakeState([
AgentMessage(role="user", content="hi"),
AgentMessage(role="assistant", stop_reason="error",
error_message="connection error"),
])
fa = _FakeAgent()
runner = AgentRunner(fa, summarize_fn=None)
n_before = len(window.db.get_message_chain(sid2))
retried = runner._prepare_retry(fa.config.retry, reason="connection error")
n_after = len(window.db.get_message_chain(sid2))
check("T3.1 _prepare_retry 生效(可重试错误)", retried is True)
check("T3.2 重试只从**内存**移除坏消息(轮次未结束,不该入库)",
len(fa.state.messages) == 1 and fa.state.messages[-1].role == "user",
str([m.role for m in fa.state.messages]))
check("T3.3 ★重试前后 DB 行数不变(不会堆一堆错误行)", n_before == n_after,
f"{n_before} -> {n_after}")
# 正常成功轮 → 不写 error 列
sid3 = window.db.create_session("T3 正常轮")["id"]
uid3 = window.db.add_message(session_id=sid3, role="user", content="你好",
parent_id=None)["id"]
st3 = {"msg_id": "msg_ok_1", "parent_id": uid3, "previous_leaf_id": uid3,
"content": "你好,我在。", "reasoning": "", "timeline": [], "usage": {},
"tl_kind": "text", "worker": None}
window.current_session_id = sid3
window._active_streams[sid3] = st3
window.on_reply_finished(sid3)
settle(150)
ok_row = window.db.get_message_chain(sid3)[-1]
check("T3.4 正常轮 stop_reason 为空(与错误行可区分)",
not ok_row.get("stop_reason"), f"{ok_row.get('stop_reason')!r}")
# ======================================================================
# T4 孤儿工具 → 合成结果(对照 pi insertSyntheticToolResults
# ======================================================================
sid4 = window.db.create_session("T4 孤儿工具")["id"]
uid4 = window.db.add_message(session_id=sid4, role="user", content="跑个命令",
parent_id=None)["id"]
mk_failed_turn(sid4, body="", tools=[tool_entry("orphan1", "bash", "{}", None, "")])
ctx4 = window.build_api_context(sid4)
tool4 = [m for m in ctx4 if m.get("role") == "tool"]
check("T4.1 孤儿工具也有配对结果", len(tool4) == 1, str(len(tool4)))
check("T4.2 孤儿结果是合成说明(不是空串,避免服务商拒绝)",
tool4 and "未收到" in tool4[0]["content"], tool4[0]["content"] if tool4 else "")
check("T4.3 孤儿工具的 tool_call 同时存在",
any(tc["id"] == "orphan1" for m in ctx4 if m.get("role") == "assistant"
for tc in (m.get("tool_calls") or [])))
# ======================================================================
# T5 叶子前进(不再时光倒流)
# ======================================================================
check("T5.1 会话叶子 = 错误行(叶子前进,不再回退到提问)",
window.db.get_session_leaf(sid2) == last2["id"],
f"leaf={window.db.get_session_leaf(sid2)} last={last2['id']}")
check("T5.2 错误行 parent_id 指向本轮提问",
last2["parent_id"] == uid2, f"{last2['parent_id']} vs {uid2}")
check("T5.3 链上顺序 = [user, assistant(error)]",
[m["role"] for m in chain2] == ["user", "assistant"],
str([m["role"] for m in chain2]))
check("T5.4 下次提问可接着链(叶子非空 → 可继续)",
window.db.get_session_leaf(sid2) is not None)
# ======================================================================
# T6 压缩切点交互
# ======================================================================
sid6 = window.db.create_session("T6 压缩交互")["id"]
u6 = window.db.add_message(session_id=sid6, role="user", content="老问题",
parent_id=None)["id"]
mk_failed_turn(sid6, body="中途断了", tools=[])
err6 = window.db.get_message_chain(sid6)[-1]
u6b = window.db.add_message(session_id=sid6, role="user", content="新问题",
parent_id=err6["id"])["id"]
a6 = window.db.add_message(session_id=sid6, role="assistant", content="新回答",
parent_id=u6b)["id"]
window.db.insert_compaction_mark(sid6, "【摘要】老问题与中断", err6["id"], u6b,
json.dumps({"path": "test", "before": 1, "after": 1}))
ctx6 = window.build_api_context(sid6)
flat6 = json.dumps(ctx6, ensure_ascii=False)
check("T6.1 切点之前的错误行 → 不进上下文(已出上下文)",
"中途断了" not in flat6 and "⚠️" not in flat6)
check("T6.2 摘要进上下文 + 切点之后照常",
"【摘要】老问题与中断" in flat6 and "新回答" in flat6)
# ======================================================================
# T7 旧库自动迁移(无新列 → 补齐;旧行 stop_reason=NULL
# ======================================================================
_OLD = os.path.join(tempfile.gettempdir(), f"haocode_old_schema_{os.getpid()}.db")
if os.path.exists(_OLD):
os.remove(_OLD)
_c = sqlite3.connect(_OLD)
_c.executescript("""
CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER);
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
reasoning TEXT, is_ignored BOOLEAN, created_at INTEGER,
attachment_metadata TEXT, parent_id TEXT, timeline TEXT, usage TEXT);
""")
_c.execute("INSERT INTO sessions VALUES ('s1','旧会话',1,1)")
_c.execute("INSERT INTO messages (id,session_id,role,content,created_at,parent_id) "
"VALUES ('m1','s1','user','你好',1,NULL)")
_c.commit()
_c.close()
d_old = DBManager(db_path=_OLD)
cols = [r[1] for r in d_old.get_connection().execute("PRAGMA table_info(messages)")]
check("T7.1 旧库自动补上 stop_reason / error_message 列",
"stop_reason" in cols and "error_message" in cols, str(cols))
row_old = d_old.get_message_chain("s1")
check("T7.2 旧行读出来 stop_reason 为空(行为与升级前一致)",
row_old and not row_old[-1].get("stop_reason"),
str(row_old[-1].get("stop_reason") if row_old else "no row"))
check("T7.3 旧库可正常写入错误行(新列可用)",
d_old.add_message(session_id="s1", role="assistant", content="⚠️ 中断",
parent_id="m1", stop_reason="error",
error_message="boom")["id"] is not None)
check("T7.4 旧库错误行可读回",
d_old.get_message_chain("s1")[-1].get("error_message") == "boom")
# ======================================================================
# T8 copy_session 携带新列
# ======================================================================
copy8 = window.db.copy_session(sid2)
chain8 = window.db.get_message_chain(copy8["id"])
last8 = chain8[-1]
check("T8.1 复制后错误行保留 stop_reason/error_message",
last8.get("stop_reason") == "error" and "Connection error" in (last8.get("error_message") or ""),
f"{last8.get('stop_reason')} / {last8.get('error_message')}")
check("T8.2 复制后 timeline 工具条目一致",
len(json.loads(last8["timeline"] or "[]")) == len(json.loads(last2["timeline"] or "[]")))
check("T8.3 复制后 is_ignored 一致",
int(last8.get("is_ignored") or 0) == int(last2.get("is_ignored") or 0))
check("T8.4 复制后链条角色一致",
[m["role"] for m in chain8] == [m["role"] for m in chain2])
# ======================================================================
# T9 ★数据安全:追加新列绝不得触发「旧数据链表化重构」
# _upgrade_schema 里 upgraded=True 会把每个会话的消息按时间拍平成线性链,
# 直接毁掉树状分支(用户的 12 个分支点!)。新列迁移必须走旁路。
# ======================================================================
_BR = os.path.join(tempfile.gettempdir(), f"haocode_branch_{os.getpid()}.db")
if os.path.exists(_BR):
os.remove(_BR)
_bc = sqlite3.connect(_BR)
_bc.executescript("""
CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER,
has_messages BOOLEAN DEFAULT 0, sort_order INTEGER DEFAULT 0,
is_starred BOOLEAN DEFAULT 0, current_leaf_msg_id TEXT, mode TEXT);
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
reasoning TEXT, is_ignored BOOLEAN, created_at INTEGER,
attachment_metadata TEXT, parent_id TEXT, timeline TEXT, usage TEXT);
""")
_bc.execute("INSERT INTO sessions VALUES ('s9','分支会话',1,1,1,0,0,'m5',NULL)")
# 链 u1(1) → a1(2) → u2(3) → {a2(4), a2b(5)} ← u2 下两个孩子 = 分支点
for _mid, _role, _ct, _par in [("m1", "user", 1, None), ("m2", "assistant", 2, "m1"),
("m3", "user", 3, "m2"), ("m4", "assistant", 4, "m3"),
("m5", "assistant", 5, "m3")]:
_bc.execute("INSERT INTO messages (id,session_id,role,content,created_at,parent_id) "
"VALUES (?,?,?,?,?,?)", (_mid, "s9", _role, _mid, _ct, _par))
_bc.commit()
_bc.close()
d_b = DBManager(db_path=_BR)
_cols_b = [r[1] for r in d_b.get_connection().execute("PRAGMA table_info(messages)")]
check("T9.1 旧库自动追加 stop_reason / error_message 列",
"stop_reason" in _cols_b and "error_message" in _cols_b, str(_cols_b))
_rows_b = {r["id"]: r for r in d_b.get_connection().execute(
"SELECT id, parent_id FROM messages WHERE session_id='s9'")}
check("T9.2 ★分支结构未被拍平(a2b.parent 仍指向分支点 u2",
_rows_b["m5"]["parent_id"] == "m3",
f"m5.parent={_rows_b['m5']['parent_id']}(拍平后会变成 m4")
check("T9.3 叶子节点未被改写", d_b.get_session_leaf("s9") == "m5",
str(d_b.get_session_leaf("s9")))
check("T9.4 原有行内容未被改动",
all(_rows_b[k]["parent_id"] == v for k, v in
[("m2", "m1"), ("m3", "m2"), ("m4", "m3")]),
str({k: _rows_b[k]["parent_id"] for k in _rows_b}))
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
for _p in (_DB_TMP, _CFG_TMP, _OLD, _BR):
try:
if os.path.exists(_p):
os.remove(_p)
except Exception:
pass
sys.exit(0 if ok else 1)
+87
View File
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""
file_reader 单元测试标准库 unittest零额外依赖
运行在项目根目录执行 python -m unittest discover tests -v
或直接 python tests/test_file_attach.py
"""
import os
import sys
import tempfile
import unittest
# 保证直接运行(python tests/xxx.py)时也能 import 到项目根下的包
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from ui.views.system_tools.file_reader import BINARY_EXTS, read_text_file # noqa: E402
class ReadTextFileTest(unittest.TestCase):
"""read_text_file:编码探测 / 二进制探测 / 大小守卫"""
def _write(self, data: bytes, suffix: str = ".txt") -> str:
fd, path = tempfile.mkstemp(suffix=suffix)
with os.fdopen(fd, "wb") as f:
f.write(data)
self.addCleanup(os.remove, path)
return path
def test_utf8_file(self):
path = self._write("你好,世界\nhello".encode("utf-8"))
content, enc, size_kb, lines = read_text_file(path)
self.assertIn("你好,世界", content)
self.assertIn("hello", content)
self.assertEqual(lines, 2)
self.assertIn(enc, ("utf-8-sig", "utf-8"))
self.assertGreater(size_kb, 0)
def test_utf8_bom_file(self):
path = self._write(b"\xef\xbb\xbf" + "带BOM".encode("utf-8"))
content, enc, _, _ = read_text_file(path)
self.assertEqual(content, "带BOM") # utf-8-sig 会吃掉 BOM
self.assertEqual(enc, "utf-8-sig")
def test_gbk_file_falls_back_to_gb18030(self):
path = self._write("中文GBK内容".encode("gbk"))
content, enc, _, _ = read_text_file(path)
self.assertEqual(content, "中文GBK内容")
self.assertEqual(enc, "gb18030")
def test_binary_file_rejected(self):
path = self._write(b"\x00\x01\x02\x03binary-payload")
with self.assertRaises(ValueError):
read_text_file(path)
def test_oversize_file_rejected(self):
path = self._write(b"a" * 100)
with self.assertRaises(ValueError):
read_text_file(path, max_bytes=10)
def test_latin1_fallback_never_fails(self):
# 0xFF 既非合法 UTF-8 也非合法 GB18030 引导字节,应由 latin-1 兜底
path = self._write(b"\xff\xfe\xfd plain text")
content, enc, _, _ = read_text_file(path)
self.assertEqual(enc, "latin-1")
self.assertIn("plain text", content)
class BinaryExtBlacklistTest(unittest.TestCase):
"""黑名单分类:Word/Excel 等被拒,常见代码/文本文件放行"""
def test_office_and_binary_blocked(self):
for ext in (".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".zip", ".exe", ".dll", ".mp4", ".ttf", ".db", ".psd"):
self.assertIn(ext, BINARY_EXTS, f"{ext} 应在黑名单中")
def test_pdf_not_blacklisted(self):
# PDF 改由专用分支(pdf_reader)处理,不再走二进制黑名单
self.assertNotIn(".pdf", BINARY_EXTS, ".pdf 应由 PDF 专用分支处理,不应在黑名单中")
def test_text_and_code_allowed(self):
for ext in (".py", ".js", ".ts", ".java", ".c", ".cpp", ".go", ".rs",
".md", ".txt", ".json", ".yaml", ".html", ".css", ".sql",
".sh", ".csv", ".log", ".ipynb", ""):
self.assertNotIn(ext, BINARY_EXTS, f"{ext} 不应在黑名单中")
if __name__ == "__main__":
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
// -*- coding: utf-8 -*-
// 公式管线单测:从 app.js 中切出【真实】公式段 + computeSafeLen,在沙箱里执行
// 运行: node tests/test_math_extract.js
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const src = fs.readFileSync(path.join(__dirname, '..', 'ui', 'web', 'app.js'), 'utf8');
// ---- 切出公式段(公式渲染 section 起点 → 全局事件委托 section 之前)----
const secStart = src.indexOf('// ==================== 🌟 公式渲染');
const secEnd = src.indexOf('// ==================== 全局事件委托');
if (secStart < 0 || secEnd < 0 || secEnd <= secStart) {
console.error('FAIL 无法切出公式段 (start=%d end=%d)', secStart, secEnd);
process.exit(1);
}
let mathCode = src.slice(secStart, secEnd);
// ---- 切出 computeSafeLen 函数(行首锚定,避开文档注释里的同名行)----
var _m = src.match(/^function computeSafeLen\(s, stableLen\) \{$/m);
if (!_m) { console.error('FAIL 无法切出 computeSafeLen'); process.exit(1); }
var csStart = _m.index; // 注意:indexOf 会命中文档注释里的同名行,必须用正则的 index
const csEnd = src.indexOf('// 初始化/获取某个容器 div 的增量渲染状态');
if (csStart < 0 || csEnd < 0) { console.error('FAIL 无法切出 computeSafeLen'); process.exit(1); }
mathCode += '\n' + src.slice(csStart, csEnd);
// ---- 沙箱 ----
const sandbox = {
console,
escapeHtml: (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
katex: { renderToString: (tex, opts) => '<KATEX d=' + (opts.displayMode ? 1 : 0) + '>' + tex + '</KATEX>' },
marked: { parse: (t) => '<MD>' + String(t), setOptions() {}, bind: null },
};
sandbox.marked.parse.bind = function () { return sandbox.marked.parse; };
vm.createContext(sandbox);
vm.runInContext(mathCode, sandbox, { filename: 'app.js-math-section' });
const extractMath = sandbox.extractMath;
const restoreMath = sandbox.restoreMath;
const findUnclosedMathFrom = sandbox.findUnclosedMathFrom;
const computeSafeLen = sandbox.computeSafeLen;
let pass = 0, fail = 0;
function check(name, cond, extra) {
if (cond) { pass++; console.log('PASS ' + name); }
else { fail++; console.log('FAIL ' + name + (extra !== undefined ? ' | ' + extra : '')); }
}
function itemsOf(text) { return extractMath(text).items; }
// ============ 1) 用户原始样本(部分供应商单括号格式)============
const userSample = '上面是推导结果:\n[\nP_4=\\operatorname{BRF}(M_4,M_5)\n]\n\n[\nP_3=\\operatorname{BRF}(S_3,P_4)\n]\n\n其中:\n[\n\\operatorname{BRF}(L,H)\nL+\n\\Gamma(L,\\operatorname{Up}(H))\n\\odot\n\\Phi(\\operatorname{Up}(H))\n]\n';
{
const ex = extractMath(userSample);
check('用户样本: 抽出 3 个块公式', ex.items.length === 3, 'got=' + ex.items.length);
check('用户样本: 全是 display', ex.items.every(i => i.display));
check('用户样本: 占位符已就位', /@@K[BI]MA/.test(ex.md));
check('用户样本: 无残留括号([ ] 已吞)', !/[\[\]]/.test(ex.md), JSON.stringify(ex.md));
const html = restoreMath(ex.md, ex.items);
check('用户样本: 恢复出 3 个 KATEX 块', (html.match(/<KATEX d=1>/g) || []).length === 3);
check('用户样本: tex 内容正确', ex.items[0].tex.includes('P_4=\\operatorname{BRF}(M_4,M_5)'));
}
// ============ 2) 标准定界符 ============
check('$$ 块', itemsOf('a $$x^2+y^2$$ b').length === 1);
check('$$ 块 display', itemsOf('$$x$$')[0].display === true);
check('\\[ \\] 块', itemsOf('\\[E=mc^2\\]').length === 1);
check('行内 $x^2$', itemsOf('能量 $E=mc^2$ 著名').length === 1);
check('行内非 display', itemsOf('$x^2$')[0].display === false);
check('\\( \\) 行内', itemsOf('\\(a+b\\)').length === 1);
check('多公式同段', itemsOf('$a^2$ 和 $$b^2$$ 及 $c$').length === 3);
// ============ 3) 防误伤 ============
check('编号 [1] 不抽', itemsOf('见[1]参考').length === 0);
check('链接 [text](url) 不抽', itemsOf('[text](http://a.b) 和 [\nfoo\n](x)').length === 0);
check('列表 [a, b] 不抽', itemsOf('[a, b] 数组').length === 0);
check('货币 $1,000 and $2,000 不抽', itemsOf('价格 $1,000 and $2,000').length === 0);
check('单词 $a$ 按变量公式(有意取舍)', itemsOf('花 $a$ 朵').length === 1);
check('多词 $a b$ 不抽', itemsOf('这 $a b$ 个').length === 0);
check('未闭合 $x 不抽', itemsOf('价格 $x 未闭合').length === 0);
check('代码块内 $y$ [0] 不抽', itemsOf('```\nx = $y$\narr[0]\n```').length === 0);
check('行内代码 `$z$` 不抽', itemsOf('使用 `$z$` 变量').length === 0);
check('未闭合围栏内不抽', itemsOf('```\n$w$ 流式中').length === 0);
// ============ 4) 混合定位 ============
{
const ex = extractMath('前文\n\n$$\na=b\n$$\n\n后文 $c$ 尾');
check('混合: 2 公式', ex.items.length === 2);
check('混合: 占位符顺序', ex.md.indexOf('@@KBMA0@@') < ex.md.indexOf('@@KMIA1@@'));
}
// ============ 5) findUnclosedMathFrom ============
check('无公式 → 0', findUnclosedMathFrom('hello\nworld\n') === 0);
check('未闭合 $$ → 位置', findUnclosedMathFrom('a\n$$x+y\n') === 2);
check('闭合 $$ → 0', findUnclosedMathFrom('a\n$$x+y\n$$\n') === 0);
check('未闭合 [ 行 → 位置', findUnclosedMathFrom('a\n[\nx=1\n') === 2);
check('闭合 [..] → 0', findUnclosedMathFrom('a\n[\nx=1\n]\n') === 0);
check('链接 [t](u) 不算开 → 0', findUnclosedMathFrom('a\n[t](u)\n') === 0);
check('行内未闭合 $ → 位置', findUnclosedMathFrom('cost $5 plus') === 5);
check('代码围栏内忽略', findUnclosedMathFrom('```\n$$x\n```\n') === 0);
// ============ 6) computeSafeLen 公式感知回退 ============
{
// “para1\n\n” = 7 字符(5 + 两个换行)。未闭合 [ 块在边界之后 → 稳定区止于 7
const s1 = 'para1\n\n[\nx=1\n';
check('流式: 未闭合 [ 前缀止于空行', computeSafeLen(s1, 0) === 7, 'got=' + computeSafeLen(s1, 0));
check('流式: 增量无进展返回 0', computeSafeLen(s1, 7) === 0);
// 闭合后 → 整个块可固化
const s2 = 'para1\n\n[\nx=1\n]\n\n';
check('流式: 闭合后可固化全部', computeSafeLen(s2, 0) === s2.length, 'got=' + computeSafeLen(s2, 0));
// 关键回退:块边界(空行)在 未闭合 $$ 之后 → 必须回退到 $$ 起点
const s3 = 'a b\n\n$$x +\ny z\n\n';
check('流式: 未闭合 $$ 触发回退到起点', computeSafeLen(s3, 0) === 5, 'got=' + computeSafeLen(s3, 0));
// 闭合 $$ 后不再回退
const s3b = 'a b\n\n$$x +\ny z$$\n\n';
check('流式: 闭合 $$ 可固化全部', computeSafeLen(s3b, 0) === s3b.length, 'got=' + computeSafeLen(s3b, 0));
// 链接不触发回退(candidate 到链接后的空行 15;若误判未闭合会回退到 7)
const s4 = 'para1\n\n[t](u)\n\ndone\n';
check('流式: 链接不触发回退', computeSafeLen(s4, 0) === 15, 'got=' + computeSafeLen(s4, 0));
}
console.log('\n===== ' + pass + ' passed, ' + fail + ' failed =====');
process.exit(fail ? 1 : 0);
+92
View File
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
"""tools/builtin_tools/pdf_reader.py 的单元测试。
PyMuPDF 现场生成含文本与内嵌图片的测试 PDF验证文本结构化提取
图片提取落地以及异常分支文件不存在 / 超过大小上限
需在装有 PyMuPDF haocode 环境运行::
python -m unittest discover tests
"""
import os
import sys
import tempfile
import unittest
# 让 tests 目录能 import 到项目根目录下的 tools 包
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
try:
import pymupdf
except ImportError: # 兼容旧版导入名
import fitz as pymupdf # type: ignore
from tools.builtin_tools.pdf_reader import (
extract_pdf_text,
extract_pdf_images,
)
def _make_png_bytes() -> bytes:
"""生成一张 8x8 红色小图的 PNG 字节流。"""
pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 8, 8), 0)
pix.set_rect(pix.irect, (255, 0, 0))
data = pix.tobytes("png")
pix = None
return data
def _build_sample_pdf(path: str) -> None:
"""造一个 2 页 PDF:第 1 页含文本 + 图片,第 2 页仅文本。"""
doc = pymupdf.open()
p1 = doc.new_page()
p1.insert_text((72, 72), "Hello PDF page one")
p1.insert_image(pymupdf.Rect(72, 100, 172, 200), stream=_make_png_bytes())
p2 = doc.new_page()
p2.insert_text((72, 72), "Second page text here")
doc.save(path)
doc.close()
class PdfReaderTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = self.tmp.name
self.pdf = os.path.join(self.dir, "sample.pdf")
_build_sample_pdf(self.pdf)
def test_extract_text_structure(self):
text, pages = extract_pdf_text(self.pdf)
self.assertEqual(pages, 2)
self.assertIn("[第 1 页]", text)
self.assertIn("[第 2 页]", text)
self.assertIn("Hello PDF page one", text)
self.assertIn("Second page text here", text)
def test_extract_images(self):
out_dir = os.path.join(self.dir, "imgs")
imgs = extract_pdf_images(self.pdf, out_dir)
self.assertGreaterEqual(len(imgs), 1)
im = imgs[0]
for key in ("page", "index", "abs_path", "mime", "size_kb", "width", "height"):
self.assertIn(key, im)
self.assertEqual(im["page"], 1)
self.assertEqual(im["mime"], "image/png")
self.assertTrue(os.path.isfile(im["abs_path"]))
self.assertGreater(os.path.getsize(im["abs_path"]), 0)
def test_missing_file(self):
with self.assertRaises(ValueError):
extract_pdf_text(os.path.join(self.dir, "nope.pdf"))
def test_oversize(self):
# 用一个极小的上限触发超大分支
with self.assertRaises(ValueError):
extract_pdf_text(self.pdf, max_bytes=10)
if __name__ == "__main__":
unittest.main()
+115
View File
@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""
离屏验证思考/压缩气泡内代码朴素渲染"紫+边框"+ 正文代码块不受影响
背景压缩摘要充满代码finishTimelineMessage 对全部 pre code hljs 高亮后
.code-block-wrapper 的浅色语法色#a626a4 紫)+ 1px 边框 + 语言栏让定格后的
压缩气泡观感"紫色带边框"修复.think-content 作用域内代码统一朴素灰
无边框无头部 min-width正文气泡代码块保持原样
"""
import os, sys, json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ["QT_QPA_PLATFORM"] = "offscreen"
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("HAOCODE_RENDER", "software")
from PyQt6.QtWidgets import QApplication # noqa: E402 铁律:WebEngine 先于 QApplication 创建导入
from PyQt6.QtCore import QTimer, QUrl # noqa: E402
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
RESULTS = []
def check(name, cond, extra=""):
RESULTS.append((name, bool(cond)))
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
GRAY = "rgb(119, 119, 119)" # #777
PURPLE = "rgb(166, 38, 164)" # #a626a4(正文代码块关键字色,保持)
SUMMARY = ("## 摘要\n说明文字。\n```python\nimport os\ndef run():\n return \"x\"\n```\n"
"另外 `flowkit` 与 `read_file` 是工具名。")
TEST_JS = r"""
(function(){
try {
var out = {};
var SUM = __SUMMARY__;
// ---------- 压缩气泡定格 + finish触发全量 hljs 兜底后探测 ----------
createMessage('m1','assistant','','Assistant');
compactionStarted('m1','pre_prompt');
compactionFinished('m1',{summary:SUM,before:100,after:50,duration_ms:10,path:'pre_prompt'});
finishMessage('m1');
(function(){
var block = document.querySelector('#m1 .compaction-think');
if (!block) { out.m1 = 'NO-BLOCK'; return; }
var wrap = block.querySelector('.code-block-wrapper');
if (!wrap) { out.m1 = 'NO-WRAP'; return; }
var cs = getComputedStyle(wrap);
out.m1_border = cs.borderTopWidth;
out.m1_minw = cs.minWidth;
var hdr = wrap.querySelector('.code-header');
out.m1_header_display = hdr ? getComputedStyle(hdr).display : 'NO-HDR';
var kw = wrap.querySelector('.hljs-keyword');
out.m1_kw_color = kw ? getComputedStyle(kw).color : 'NO-KW-SPAN';
var pre = wrap.querySelector('pre code');
out.m1_pre_color = pre ? getComputedStyle(pre).color : 'NO-PRE';
var ic = block.querySelector('p code');
out.m1_inline_color = ic ? getComputedStyle(ic).color : 'NO-INLINE';
})();
// ---------- 对照组正文消息的代码块保持原样边框+语法色 ----------
createMessage('m2','assistant','','Assistant');
appendToken('m2','前文。\n```python\nimport os\ndef run():\n return "x"\n```\n后文。');
finishMessage('m2');
(function(){
var w = document.querySelector('#m2 .code-block-wrapper');
if (!w) { out.m2 = 'NO-WRAP'; return; }
var cs = getComputedStyle(w);
out.m2_border = cs.borderTopWidth;
var hdr = w.querySelector('.code-header');
out.m2_header_display = hdr ? getComputedStyle(hdr).display : 'NO-HDR';
var kw = w.querySelector('.hljs-keyword');
out.m2_kw_color = kw ? getComputedStyle(kw).color : 'NO-KW-SPAN';
})();
return JSON.stringify(out);
} catch(e) { return 'JSERR:' + e.message + ' @' + ((e.stack||'').split('\n')[1]||''); }
})()
""".replace("__SUMMARY__", json.dumps(SUMMARY, ensure_ascii=False))
app = QApplication(sys.argv)
_js = {"v": None}
view = QWebEngineView(); view.resize(1000, 700)
_index = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "ui", "web", "index.html"))
def _run():
view.page().runJavaScript(TEST_JS, lambda v: _js.update(v=v))
view.loadFinished.connect(lambda ok: QTimer.singleShot(1500, _run))
QTimer.singleShot(9000, app.quit)
view.load(QUrl.fromLocalFile(_index))
app.exec()
try:
r = json.loads(_js["v"])
except Exception:
r = None
print(f"DOM 结果解析失败: {_js['v']!r}", flush=True)
check("T.DOM 返回", isinstance(r, dict), repr(_js["v"])[:300])
if isinstance(r, dict):
# 压缩气泡内:朴素化
check("T.压缩气泡代码块边框=0", r.get("m1_border") == "0px", r)
check("T.压缩气泡代码块min-width=0", r.get("m1_minw") == "0px", r)
check("T.压缩气泡代码头部=隐藏", r.get("m1_header_display") == "none", r)
check("T.压缩气泡关键字色=#777(去紫)", r.get("m1_kw_color") == GRAY, r.get("m1_kw_color"))
check("T.压缩气泡pre代码色=#777", r.get("m1_pre_color") == GRAY, r.get("m1_pre_color"))
check("T.压缩气泡内联代码色=#777(去粉)", r.get("m1_inline_color") == GRAY, r.get("m1_inline_color"))
# 正文对照:保持原样
check("T.正文代码块边框=1px(不变)", r.get("m2_border") == "1px", r)
check("T.正文代码头部=可见(不变)", r.get("m2_header_display") not in ("none", "NO-HDR"), r)
check("T.正文关键字色=#a626a4(不变)", r.get("m2_kw_color") == PURPLE, r.get("m2_kw_color"))
failed = [n for n, ok in RESULTS if not ok]
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
sys.exit(0 if not failed else 1)
+255
View File
@@ -0,0 +1,255 @@
# -*- coding: utf-8 -*-
"""
四大工具参数层单测纯函数级 DB/ UI 依赖
覆盖本轮修复
T1-T9 参数归一化 + JSON Schema 校验全错误上报 / null 归一化 / 轻量强制转换 / bool 漏洞
T10-T13 edit 参数预处理legacy 单条 / edits JSON 字符串
T14-T19 edit 区间规划唯一性 / 重叠检测 / oldText
T20-T25 read 参数钳制limit 负数漏洞 / 越界 / 空文件
T26-T29 write + edit 原子写无临时文件残留
T30-T31 before 钩子改参后重新校验
运行: PYTHONIOENCODING=utf-8 python tests/test_tool_params.py
"""
import os
import sys
import json
import glob
import tempfile
import shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.agent.tools import (validate_json_schema, normalize_and_coerce, # noqa: E402
_prepare_edit_args, _plan_edits,
tool_read, tool_write, tool_edit,
execute_tool_call, PreparedToolCall)
from core.agent.types import (AgentTool, AgentToolResult, AbortSignal, # noqa: E402
ToolCall, AgentMessage)
RESULTS = []
def check(name, cond, extra=""):
RESULTS.append((name, bool(cond)))
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
# ======================================================================
# 测试用 schema(与 default_tools() 一致)
# ======================================================================
S_READ = {"type": "object",
"properties": {"path": {"type": "string"},
"offset": {"type": "integer"},
"limit": {"type": "integer"}},
"required": ["path"]}
S_BASH = {"type": "object",
"properties": {"command": {"type": "string"},
"timeout": {"type": "number"}},
"required": ["command"]}
S_EDIT = {"type": "object",
"properties": {
"path": {"type": "string"},
"edits": {"type": "array",
"items": {"type": "object",
"properties": {"oldText": {"type": "string"},
"newText": {"type": "string"}},
"required": ["oldText"]}}},
"required": ["path", "edits"]}
SG = AbortSignal()
# ======================================================================
# T1-T9 校验层
# ======================================================================
def err_of(args, schema):
return validate_json_schema(normalize_and_coerce(args, schema), schema)
check("T1.缺必填参数", err_of({"offset": 1}, S_READ) == "缺少必填参数: path",
err_of({"offset": 1}, S_READ))
check("T2.string 类型错误", "应为 string" in (err_of({"path": 123}, S_READ) or ""),
err_of({"path": 123}, S_READ))
check("T3.bool 不得冒充 integer(旧版漏洞)",
"应为 integer" in (err_of({"path": "a", "offset": True}, S_READ) or ""),
err_of({"path": "a", "offset": True}, S_READ))
check("T4.bool 不得冒充 number(旧版漏洞)",
"应为 number" in (err_of({"command": "x", "timeout": True}, S_BASH) or ""),
err_of({"command": "x", "timeout": True}, S_BASH))
check("T5.整数 1.5 拒绝", "期望 integer" in (err_of({"path": "a", "offset": 1.5}, S_READ) or ""),
err_of({"path": "a", "offset": 1.5}, S_READ))
check("T6.多余参数宽容(与 pi 一致)", err_of({"path": "a", "lines": 10}, S_READ) is None,
err_of({"path": "a", "lines": 10}, S_READ))
_err2 = err_of({"path": 123, "offset": "bad"}, S_READ) or ""
check("T7.多错全量上报(不再只报第 1 个)",
_err2.count(";") >= 1 and "path" in _err2 and "offset" in _err2, _err2)
check("T8.嵌套必填字段",
err_of({"path": "a", "edits": [{"newText": "y"}]}, S_EDIT)
== "参数 edits[0] 缺少必填字段 oldText",
err_of({"path": "a", "edits": [{"newText": "y"}]}, S_EDIT))
check("T9.数组元素类型错误定位到下标",
"edits[1]" in (err_of({"path": "a", "edits": [{"oldText": "x"}, {"oldText": 5}]}, S_EDIT) or ""),
err_of({"path": "a", "edits": [{"oldText": "x"}, {"oldText": 5}]}, S_EDIT))
# ---- 归一化 / 强制转换 ----
check("T10.可选字段 null 被删除(不再误报类型错)",
normalize_and_coerce({"path": "a", "offset": None}, S_READ) == {"path": "a"})
check("T11.数字字符串被转换 \"30\"→30",
normalize_and_coerce({"command": "x", "timeout": "30"}, S_BASH)["timeout"] == 30)
check("T12.integer 字段收 \"1.5\" 保持原值(交由校验报错)",
normalize_and_coerce({"path": "a", "offset": "1.5"}, S_READ)["offset"] == "1.5")
_orig = {"path": "a", "offset": None}
normalize_and_coerce(_orig, S_READ)
check("T13.归一化不修改入参", _orig == {"path": "a", "offset": None}, _orig)
# ======================================================================
# T14-T15 edit 参数预处理
# ======================================================================
check("T14.legacy 单条形式 {oldText,newText} → edits[]",
_prepare_edit_args({"path": "a", "oldText": "x", "newText": "y"})
== {"path": "a", "edits": [{"oldText": "x", "newText": "y"}]})
check("T15.edits 为 JSON 字符串 → 解析为数组",
_prepare_edit_args({"path": "a", "edits": '[{"oldText":"x"}]'})
== {"path": "a", "edits": [{"oldText": "x"}]})
# ======================================================================
# T16-T19 edit 区间规划
# ======================================================================
_e, _sp = _plan_edits("hello world", [{"oldText": "world", "newText": "there"}])
check("T16.唯一命中 → 规划成功", _e is None and _sp[0][0] == 6, (_e, _sp))
check("T17.未命中 → 报错", "未找到匹配文本" in (_plan_edits("abc", [{"oldText": "zz"}])[0] or ""),
_plan_edits("abc", [{"oldText": "zz"}])[0])
check("T18.重复命中 → 报错", "匹配到 2 处" in (_plan_edits("foo foo", [{"oldText": "foo"}])[0] or ""),
_plan_edits("foo foo", [{"oldText": "foo"}])[0])
# 旧版会:两条对原始内容各自唯一 → 校验通过 → 应用时第 2 条已找不到(静默 no-op,仍报“已应用 2 处”)
# 现在必须判定为重叠并整批拒绝
_e2 = _plan_edits("foo bar", [{"oldText": "foo bar", "newText": "foo BAR"},
{"oldText": "bar", "newText": "baz"}])[0]
check("T19.区间重叠被检出(旧版静默 no-op 第 2 条)", "重叠" in (_e2 or ""), _e2)
check("T19b.嵌套重叠被检出",
"重叠" in (_plan_edits("abc", [{"oldText": "abc", "newText": "Z"},
{"oldText": "ab", "newText": "Q"}])[0] or ""))
check("T19d.原文件不存在的 oldText 被拒(对齐 pi:对原始快照匹配)",
"未找到匹配文本" in (_plan_edits("abc", [{"oldText": "ab", "newText": "aX"},
{"oldText": "aXb", "newText": "ZZ"}])[0] or ""))
check("T19c.空 oldText 被拒(旧版空文件会静默插入)",
"不能为空" in (_plan_edits("", [{"oldText": "", "newText": "INJ"}])[0] or ""),
_plan_edits("", [{"oldText": "", "newText": "INJ"}])[0])
# ======================================================================
# T20-T25 read 参数钳制(临时文件,不碰项目数据)
# ======================================================================
_TMP = tempfile.mkdtemp(prefix="haocode_tp_")
CTX = {"cwd": _TMP}
def _mk(name, text):
p = os.path.join(_TMP, name)
with open(p, "w", encoding="utf-8") as f:
f.write(text)
return p
try:
_mk("lines.txt", "".join(f"L{i}\n" for i in range(1, 11))) # 10 行
_mk("empty.txt", "")
r = tool_read("t", {"path": "lines.txt", "limit": -5}, SG, None, CTX)
_t20 = r.as_text()
check("T20.limit 负数不再读全文件(旧版返回 10 行减 5)",
"L1" in _t20 and "L2" not in _t20, _t20.replace("\n", " | ")[:120])
r = tool_read("t", {"path": "lines.txt", "limit": 0}, SG, None, CTX)
check("T21.limit=0 钳到 1(旧版返回空块 + 无意义脚注)",
"L1" in r.as_text() and "L2" not in r.as_text(), r.as_text().replace("\n", " | ")[:120])
r = tool_read("t", {"path": "lines.txt", "offset": 999}, SG, None, CTX)
check("T22.offset 越界 → 明确提示(旧版'已显示 999998 行'",
"超出文件范围" in r.as_text() and "共 10 行" in r.as_text(), r.as_text())
r = tool_read("t", {"path": "empty.txt"}, SG, None, CTX)
check("T23.空文件 → 明确提示", "文件为空" in r.as_text(), r.as_text())
r = tool_read("t", {"path": ""}, SG, None, CTX)
check("T24.path 为空 → 'path 不能为空'(旧版报'文件不存在: <cwd>'",
r.is_error and "不能为空" in r.as_text(), r.as_text())
r = tool_read("t", {"path": "lines.txt", "offset": 3, "limit": 2}, SG, None, CTX)
check("T25.正常行窗口不变(offset=3,limit=2 → L3,L4",
"L3" in r.as_text() and "L4" in r.as_text() and "L5" not in r.as_text()
and "offset=5" in r.as_text(), r.as_text().replace("\n", " | ")[:140])
# ==================================================================
# T26-T29 原子写 / edit 端到端
# ==================================================================
r = tool_write("t", {"path": "w.txt", "content": "a\nb\n"}, SG, None, CTX)
left = glob.glob(os.path.join(_TMP, ".hocode_w_*"))
check("T26.write 成功且无临时文件残留", not r.is_error and not left, (r.as_text(), left))
tool_write("t", {"path": "w2.txt", "content": "foo bar"}, SG, None, CTX)
r = tool_edit("t", {"path": "w2.txt",
"edits": [{"oldText": "foo bar", "newText": "foo BAR"},
{"oldText": "bar", "newText": "baz"}]}, SG, None, CTX)
check("T27.重叠 edit 整批拒绝且文件未被改动",
r.is_error and "重叠" in r.as_text(), r.as_text())
r = tool_edit("t", {"path": "w.txt",
"edits": [{"oldText": "a", "newText": "A"},
{"oldText": "b", "newText": "B"}]}, SG, None, CTX)
with open(os.path.join(_TMP, "w.txt"), encoding="utf-8") as f:
after = f.read()
check("T28.多条不重叠 edit 一次性应用(对原始快照)",
not r.is_error and after == "A\nB\n", (r.as_text(), repr(after)))
r = tool_edit("t", {"path": "w.txt", "edits": [{"oldText": "", "newText": "X"}]}, SG, None, CTX)
with open(os.path.join(_TMP, "w.txt"), encoding="utf-8") as f:
after = f.read()
check("T29.空 oldText 被拒且文件未变", r.is_error and after == "A\nB\n",
(r.as_text(), repr(after)))
# ==================================================================
# T30-T31 before 钩子改参后重新校验
# ==================================================================
def _echo(args):
return AgentToolResult.text(f"echo:{json.dumps(args, sort_keys=True)}")
def _echo5(tool_call_id, args, signal, on_update, ctx):
return _echo(args)
class _Cfg:
tool_context = {"cwd": _TMP}
before_tool_call = None
after_tool_call = None
etool = AgentTool(name="echo", description="d", parameters=S_READ, execute=_echo5)
tc = ToolCall(id="c1", name="echo", arguments={"path": "a"})
cfg_bad = _Cfg()
cfg_bad.before_tool_call = lambda payload, sig: {"args": {"path": 123}}
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
AgentMessage(role="assistant", content=""), cfg_bad, SG, None)
check("T30.before 钩子返回非法参数 → 拒绝执行(pi 同款重校验)",
res.is_error and "校验失败" in res.as_text(), res.as_text())
cfg_ok = _Cfg()
cfg_ok.before_tool_call = lambda payload, sig: {"args": {"path": "b", "offset": "7"}}
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
AgentMessage(role="assistant", content=""), cfg_ok, SG, None)
check("T31.before 钩子改参后归一化生效(\"7\"→7",
not res.is_error and '"offset": 7' in res.as_text(), res.as_text())
cfg_blk = _Cfg()
cfg_blk.before_tool_call = lambda payload, sig: {"block": True, "reason": "nope"}
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
AgentMessage(role="assistant", content=""), cfg_blk, SG, None)
check("T32.before 钩子 block 语义不变", res.is_error and "nope" in res.as_text(), res.as_text())
finally:
shutil.rmtree(_TMP, ignore_errors=True)
failed = [n for n, ok in RESULTS if not ok]
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
sys.exit(0 if not failed else 1)
+129
View File
@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
"""
WebView2 守卫单测 锁死 T0 级事故不再复发
事故链已发生
每个 MainWindow() offscreen 测试 get_environment()
无条件 `taskkill /F /IM msedgewebview2.exe`
正在运行的生产 app WebView2 浏览器进程一并杀掉
它的 controller disposedset_bounds 0x8007139F
DOM 照渲染但视觉层永久空白选中会话不渲染核心内容
覆盖
T1 无头环境QT_QPA_PLATFORM=offscreen 不启用 WebView2
T2 HAOCODE_FORCE_QTWEBENGINE=1 不启用
T3 桌面平台QT_QPA_PLATFORM=windows 允许
T4 单实例锁持有者独占另一个进程拿不到跨进程互斥是真守卫
T5 offscreen get_environment() 直接返回 None根本不碰共享 profile
T6 同一进程重复 acquire 幂等返回 True不会把自己锁死
运行: PYTHONIOENCODING=utf-8 python tests/test_wv2_guard.py
"""
import os
import sys
import tempfile
import subprocess
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 铁律:测试用独立锁文件,不得与正在运行的 app 争 data/app_instance.lock
# (否则断言会依赖“app 是否在跑”)
_LOCK_TMP = os.path.join(tempfile.gettempdir(), "haocode_wv2guard_%d.lock" % os.getpid())
os.environ["HAOCODE_INSTANCE_LOCK_FILE"] = _LOCK_TMP
if os.path.exists(_LOCK_TMP):
try:
os.remove(_LOCK_TMP)
except Exception:
pass
RESULTS = []
def check(name, cond, extra=""):
RESULTS.append((name, bool(cond)))
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
import core.webview2 as W # noqa: E402
_ORIG = dict(os.environ)
try:
# ---------------- T1/T2/T3: 环境守卫 ----------------
os.environ["QT_QPA_PLATFORM"] = "offscreen"
os.environ.pop("HAOCODE_FORCE_QTWEBENGINE", None)
check("T1.1 offscreen → 不允许 WebView2", W._wv2_allowed_here() is False)
os.environ["QT_QPA_PLATFORM"] = "windows"
check("T1.2 windows → 允许 WebView2", W._wv2_allowed_here() is True)
for p in ("minimal", "minimalegl", "vnc", "wayland", "embedded", "offscreen"):
os.environ["QT_QPA_PLATFORM"] = p
if W._wv2_allowed_here() is not False:
check(f"T1.3 平台 {p} 应被拒绝", False, p)
break
else:
check("T1.3 非 windows 平台全部拒绝", True)
os.environ["QT_QPA_PLATFORM"] = "windows"
os.environ["HAOCODE_FORCE_QTWEBENGINE"] = "1"
check("T2.1 强制回退开关生效", W._wv2_allowed_here() is False)
os.environ.pop("HAOCODE_FORCE_QTWEBENGINE", None)
# ---------------- T4: 跨进程单实例互斥(核心) ----------------
os.environ["QT_QPA_PLATFORM"] = "windows"
W._INSTANCE_LOCK["fh"] = None # 从头开始,避免受本进程历史影响
first = W.acquire_instance_lock()
check("T4.1 首个 acquires 成功", first is True, str(first))
check("T4.2 同进程重复 acquire 幂等为 True",
W.acquire_instance_lock() is True)
code = ("import sys; sys.path.insert(0, r'%s');"
"import core.webview2 as W; print(W.acquire_instance_lock())"
% os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
r = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=60)
got = (r.stdout or "").strip().splitlines()
got = got[-1] if got else ""
check("T4.3 另一进程拿不到锁(互斥生效)", got == "False", f"stdout={got!r} err={(r.stderr or '')[:120]}")
# ---------------- T5: offscreen 下 get_environment 直接 None ----------------
os.environ["QT_QPA_PLATFORM"] = "offscreen"
W._env = None
env = W.get_environment(None)
check("T5.1 offscreen 下 get_environment → None(不碰共享 profile", env is None)
check("T5.2 被守卫拦下后 _env 仍为空", W._env is None)
# ---------------- T6: 已有实例在跑时 → 不启用 WebView2 ----------------
# 此时本进程仍持有锁;用子进程模拟“后来的实例”
code2 = ("import sys; sys.path.insert(0, r'%s');"
"import core.webview2 as W; print(W.get_environment(None) is None)"
% os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
r2 = subprocess.run([sys.executable, "-c", code2], capture_output=True,
text=True, encoding="utf-8", errors="replace",
env={**os.environ, "QT_QPA_PLATFORM": "windows"},
timeout=60)
out2 = (r2.stdout or "").strip().splitlines()
out2 = out2[-1] if out2 else ""
check("T6.1 已有实例持锁 → 后来的实例拿到 None(回落 QtWebEngine,不 taskkill",
out2 == "True", f"stdout={out2!r} err={(r2.stderr or '')[:160]}")
finally:
try:
fh = W._INSTANCE_LOCK.get("fh")
if fh is not None:
fh.close()
except Exception:
pass
try:
if os.path.exists(_LOCK_TMP):
os.remove(_LOCK_TMP)
except Exception:
pass
os.environ.clear()
os.environ.update(_ORIG)
failed = [n for n, ok in RESULTS if not ok]
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
sys.exit(0 if not failed else 1)
+186
View File
@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
"""会话模式弹窗 · 可视化调参工具
运行: C:\\Users\\14890\\miniconda3\\envs\\haocode\\python.exe tests\\tune_mode_popup.py
- 左侧调参窗拖动/修改参数 右侧弹窗实时重渲染
- 右侧真实的 SessionModePopup 本体独立窗口点调参窗不会消失
- 满意后点确定 参数 JSON 写入 data/mode_popup_tune.json 自动退出
"""
import os
import sys
import json
import traceback
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("HAOCODE_RENDER", "software")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QFormLayout, QHBoxLayout, QSpinBox,
QPushButton, QCheckBox, QMessageBox,
)
from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtGui import QPixmap # noqa: E402
from ui.views.main_window import SessionModePopup, _ModeRow, _popup_svg_path # noqa: E402
DEFAULTS = {
"icon_size": 14, # 图标尺寸
"name_font": 12, # 名称字号
"desc_font": 12, # 描述字号
"row_margin_v": 8, # 行上下内边距
"row_margin_h": 10, # 行左右内边距
"icon_text_gap": 12, # 图标-文字间距
"name_desc_gap": 4, # 名称/描述行间距
"row_radius": 8, # 行圆角
"popup_width": 320, # 弹窗宽度
"container_margin": 4, # 容器边距
}
RANGES = {
"icon_size": (8, 48), "name_font": (10, 20), "desc_font": (9, 18),
"row_margin_v": (2, 20), "row_margin_h": (4, 24), "icon_text_gap": (4, 24),
"name_desc_gap": (0, 8), "row_radius": (0, 16), "popup_width": (240, 420),
"container_margin": (0, 16),
}
CN = {
"icon_size": "图标尺寸 (px)",
"name_font": "名称字号 (px)",
"desc_font": "描述字号 (px)",
"row_margin_v": "行上下内边距 (px)",
"row_margin_h": "行左右内边距 (px)",
"icon_text_gap": "图标-文字间距 (px)",
"name_desc_gap": "名称/描述行距 (px)",
"row_radius": "行圆角 (px)",
"popup_width": "弹窗宽度 (px)",
"container_margin": "容器边距 (px)",
}
FONT_STACK = '"HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif'
class Tuner:
def __init__(self):
self.app = QApplication(sys.argv)
self.params = dict(DEFAULTS)
# ---- 弹窗本体(真实类):去掉 Popup 标志 → 独立普通无边框窗,不随失焦消失 ----
self.popup = SessionModePopup(None)
self.popup.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint)
self.rows = self.popup.container.findChildren(_ModeRow)
icon_by_mode = {m: _popup_svg_path(ic) for m, _, _, ic in SessionModePopup.MODES}
for r in self.rows:
r._tune_pix = QPixmap(icon_by_mode[r.mode]) # 原始分辨率,重缩不糊
screen = self.app.primaryScreen().availableGeometry()
self.px = screen.center().x() - 300
self.py = screen.center().y() - 200
self.popup.move(self.px, self.py)
self.popup.show()
# ---- 调参窗 ----
self.win = QWidget()
self.win.setWindowTitle("会话模式弹窗调参 —— 调好后点「确定」")
form = QFormLayout(self.win)
form.setSpacing(8)
self.spins = {}
for key, (lo, hi) in RANGES.items():
sp = QSpinBox()
sp.setRange(lo, hi)
sp.setValue(self.params[key])
sp.valueChanged.connect(lambda v, k=key: self.on_change(k, v))
self.spins[key] = sp
form.addRow(CN[key], sp)
self.chk_selected = QCheckBox("显示选中态(预览)")
self.chk_selected.toggled.connect(lambda _: self.apply_all())
form.addRow(self.chk_selected)
btns = QHBoxLayout()
b_reset = QPushButton("重置默认")
b_reset.clicked.connect(self.reset)
b_ok = QPushButton("✅ 确定(写入日志并退出)")
b_ok.setStyleSheet("font-weight: bold;")
b_ok.clicked.connect(self.finish)
btns.addWidget(b_reset)
btns.addWidget(b_ok)
form.addRow(btns)
self.win.resize(360, self.win.sizeHint().height())
self.win.move(self.px - 380, self.py)
self.win.show()
self.apply_all()
# ---------- 实时渲染 ----------
def on_change(self, k, v):
self.params[k] = v
self.apply_all()
def apply_all(self):
p = self.params
popup = self.popup
popup.setFixedWidth(p["popup_width"])
cl = popup.container.layout()
cl.setContentsMargins(p["container_margin"], p["container_margin"],
p["container_margin"], p["container_margin"])
sel = self.chk_selected.isChecked()
for r in self.rows:
r.lbl_icon.setFixedSize(p["icon_size"], p["icon_size"])
r.lbl_icon.setPixmap(r._tune_pix.scaled(
p["icon_size"], p["icon_size"],
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation))
lay = r.layout()
lay.setContentsMargins(p["row_margin_h"], p["row_margin_v"],
p["row_margin_h"] + 2, p["row_margin_v"])
lay.setSpacing(p["icon_text_gap"])
txt = lay.itemAt(1).layout()
if txt is not None:
txt.setSpacing(p["name_desc_gap"])
name_color = "#1a73e8" if sel else "#333333"
r.lbl_name.setStyleSheet(
f"font-family: {FONT_STACK}; font-size: {p['name_font']}px;"
f"font-weight: bold; color: {name_color}; background: transparent;")
r.lbl_desc.setStyleSheet(
f"font-family: {FONT_STACK}; font-size: {p['desc_font']}px;"
f"color: #888888; background: transparent;")
bg = "#e8f0fe" if sel else "transparent"
r.setStyleSheet(f"background-color: {bg}; border-radius: {p['row_radius']}px;")
self.app.processEvents()
popup.adjust_popup_height()
popup.adjustSize()
def reset(self):
self.params = dict(DEFAULTS)
for k, sp in self.spins.items():
sp.blockSignals(True)
sp.setValue(self.params[k])
sp.blockSignals(False)
self.apply_all()
# ---------- 确定 → 写日志 ----------
def finish(self):
log = os.path.join(os.path.dirname(__file__), "..", "data", "mode_popup_tune.json")
os.makedirs(os.path.dirname(log), exist_ok=True)
with open(log, "w", encoding="utf-8") as f:
json.dump(self.params, f, ensure_ascii=False, indent=2)
print("TUNE_LOG=" + os.path.abspath(log), flush=True)
print(json.dumps(self.params, ensure_ascii=False), flush=True)
self.app.quit()
def main():
try:
Tuner()
QApplication.instance().exec()
except Exception:
err = traceback.format_exc()
try:
with open(os.path.join(os.path.dirname(__file__), "..", "data",
"tune_error.log"), "w", encoding="utf-8") as f:
f.write(err)
except OSError:
pass
QMessageBox.critical(None, "调参工具启动失败", err)
sys.exit(1)
if __name__ == "__main__":
main()
+399
View File
@@ -0,0 +1,399 @@
# -*- coding: utf-8 -*-
"""模型选择弹窗 · 可视化调参工具
运行: C:\\Users\\14890\\miniconda3\\envs\\haocode\\python.exe tests\\tune_model_popup.py
- 左侧调参窗改参数 右侧弹窗实时重渲染
- 右侧真实的 ModelSelectPopup 本体独立窗口点调参窗不会消失
- 满意后点确定 参数 JSON 写入 data/model_popup_tune.json 自动退出
"""
import os
import sys
import json
import traceback
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("HAOCODE_RENDER", "software")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QFormLayout, QHBoxLayout, QSpinBox,
QPushButton, QCheckBox, QLabel, QMessageBox, QComboBox,
)
from PyQt6.QtCore import Qt, QSize # noqa: E402
from PyQt6.QtGui import QPixmap, QIcon, QPainter, QFont, QFontDatabase # noqa: E402
from ui.views.main_window import ModelSelectPopup, MainWindow, _popup_svg_path # noqa: E402 (先于 QApplication)
DEFAULTS = {
# —— 字体 ——
"font_family": "Microsoft YaHei", # 字体族(下拉选择;调参确认值)
# —— 模型行 ——
"item_font": 13, # 模型名字号(经 list_widget.setFont 生效)
"model_item_h": 27, # 模型行高
"model_icon": 16, # 模型图标尺寸
"model_icon_pad": 34, # 模型行左边距(图标透明左垫,仅作用模型行)
# —— 供应商行 ——
"header_height": 38, # 供应商行高(= widget 高;默认 38 = 当前代码实际行高)
"header_font": 13, # 供应商名字号
"count_font": 10, # 数量字号
"provider_svg": "provider.svg", # 供应商图标(3 个 SVG 可切换)
"header_icon": 16, # 供应商图标尺寸
"header_icon_pad": 0, # 供应商图标水平位置(左侧留白,仅推图标与后续内容)
"header_icon_pad_v": 1, # 供应商图标垂直位置(正=下移,负=上移)
"arrow_size": 13, # 展开/收起箭头尺寸
"header_hpad_l": 3, # 供应商行左边距(头部按钮左 margin)
# —— 整体 ——
"popup_width": 340, # 弹窗宽度
"container_vmargin": 4, # 容器上下边距
}
RANGES = {
"item_font": (10, 20),
"model_item_h": (18, 40), "model_icon": (8, 28),
"model_icon_pad": (0, 48), "header_height": (20, 60), "header_font": (9, 18),
"count_font": (8, 16), "header_icon": (10, 28), "header_icon_pad": (0, 30),
"header_icon_pad_v": (-10, 10),
"arrow_size": (8, 20),
"header_hpad_l": (0, 20),
"popup_width": (260, 420), "container_vmargin": (0, 12),
}
CN = {
"font_family": "字体(下拉选择)",
"item_font": "模型名字号 (px)",
"model_item_h": "模型行高 (px)",
"model_icon": "模型图标尺寸 (px)", "model_icon_pad": "模型行左边距 (px)",
"header_height": "供应商行高 (px)", "header_font": "供应商名字号 (px)",
"count_font": "数量字号 (px)", "header_icon": "供应商图标尺寸 (px)",
"provider_svg": "供应商图标(SVG 切换)",
"header_icon_pad": "供应商图标水平位置 (px)",
"header_icon_pad_v": "供应商图标垂直位置 (px,正=下移)",
"arrow_size": "箭头尺寸 (px)", "header_hpad_l": "供应商行左边距 (px)",
"popup_width": "弹窗宽度 (px)",
"container_vmargin": "容器上下边距 (px)",
}
SECTIONS = [
("字体", ["font_family"]),
("模型行", ["item_font", "model_item_h", "model_icon", "model_icon_pad"]),
("供应商行", ["header_height", "header_font", "count_font",
"provider_svg", "header_icon", "header_icon_pad",
"header_icon_pad_v", "arrow_size", "header_hpad_l"]),
("整体", ["popup_width", "container_vmargin"]),
]
# 供应商图标候选(文件名, 显示名)
PROVIDER_SVGS = [
("provider.svg", "图标 1 · 服务器"),
("provider2.svg", "图标 2 · 二级服务器"),
("provider3.svg", "图标 3 · 服务器(细线)"),
]
FONT_STACK = '"HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans SC", sans-serif'
QSS_TMPL = """
* {
font-family: FONT_STACK;
}
#popup_container {
background-color: #ffffff;
border: 1px solid #dcdcdc;
border-radius: 12px;
}
#group_toggle_btn { background: transparent; border: none; }
#group_toggle_btn:hover { background-color: #f2f5f9; border-radius: 6px; }
#model_list {
border: none;
background: transparent;
outline: none;
}
#model_list::item {
font-family: FONT_STACK;
color: #333333;
font-weight: normal;
/* 实测本 Qt/PyQt6 构建下 ::item background-color/color 生效
font-size/font 缩写/font-family/margin/padding/border-radius 全部无效
字号走 setFont行高走 sizeHint边距走图标透明垫/按钮 margin */
}
#model_list::item:hover {
background-color: #f0f4f9;
color: #111111;
}
#model_list::item:selected {
background-color: #e8f0fe;
color: #1a73e8;
font-weight: bold;
}
QScrollBar:vertical {
border: none;
background: transparent;
width: 5px;
margin: 12px 2px;
}
QScrollBar::handle:vertical {
background: #d0d0d0;
min-height: 20px;
border-radius: 3px;
}
QScrollBar::handle:vertical:hover {
background: #a0a0a0;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
"""
class Tuner:
def __init__(self):
self.app = QApplication(sys.argv)
self.params = dict(DEFAULTS)
# ---- 弹窗本体(真实类 + 真实 config + 真实祖先上下文) ----
# ⚠️ 保真关键:实际 App 中弹窗是 MainWindow 的子部件,会继承其全局样式表
# QListWidget::item 的 padding/margin/选中蓝条等);若 parent=None 渲染,
# 就会出“调试与实际不符”的矛盾 → 借 MainWindow.setup_stylesheet 把真实
# 样式表套到宿主上,弹窗挂在宿主下渲染(与生产同构)
self._host = QWidget()
MainWindow.setup_stylesheet(self._host)
self._host.resize(1, 1)
self._host.move(-200, -200)
self._host.show()
cfg_path = os.path.join(os.path.dirname(__file__), "..", "data", "config.json")
with open(cfg_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
self.popup = ModelSelectPopup(self._host, cfg)
self.popup.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint)
self.list = self.popup.list_widget
self._tag_headers()
screen = self.app.primaryScreen().availableGeometry()
self.px = screen.center().x() - 200
self.py = screen.center().y() - 300
self.popup.move(self.px, self.py)
self.popup.show()
# ---- 调参窗 ----
self.win = QWidget()
self.win.setWindowTitle("模型选择弹窗调参 —— 调好后点「确定」")
form = QFormLayout(self.win)
form.setSpacing(7)
self.spins = {}
self.combos = {}
self.broken = set() # 用户标记的“不生效”参数
for title, keys in SECTIONS:
form.addRow(QLabel(f"—— {title} ——"))
for key in keys:
mark = QPushButton("不生效")
mark.setCheckable(True)
mark.setFixedWidth(58)
mark.setCursor(Qt.CursorShape.PointingHandCursor)
mark.setStyleSheet("""
QPushButton { font-size: 11px; color: #888888; border: 1px solid #cccccc;
border-radius: 4px; background: #fafafa; }
QPushButton:checked { color: #ffffff; background: #d93025; border-color: #d93025; }
""")
mark.toggled.connect(lambda on, k=key: self.on_mark(k, on))
row = QHBoxLayout()
row.setSpacing(4)
if key == "font_family":
combo = QComboBox()
fams = ["HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans SC",
"Segoe UI", "Arial", "Consolas", "SimHei", "PingFang SC"]
seen = set()
for fam in fams + sorted(QFontDatabase.families()):
if fam and fam not in seen:
seen.add(fam)
combo.addItem(fam)
combo.setCurrentText(self.params[key])
combo.currentTextChanged.connect(lambda v, k=key: self.on_change(k, v))
self.combos[key] = combo
row.addWidget(combo, 1)
elif key == "provider_svg":
combo = QComboBox()
for fname, cname in PROVIDER_SVGS:
combo.addItem(cname, fname)
combo.setCurrentIndex(max(combo.findData(self.params[key]), 0))
combo.currentIndexChanged.connect(
lambda i, k=key, c=combo:
self.on_change(k, c.itemData(i) or "provider.svg"))
self.combos[key] = combo
row.addWidget(combo, 1)
else:
lo, hi = RANGES[key]
sp = QSpinBox()
sp.setRange(lo, hi)
sp.setValue(self.params[key])
sp.valueChanged.connect(lambda v, k=key: self.on_change(k, v))
self.spins[key] = sp
row.addWidget(sp, 1)
row.addWidget(mark)
form.addRow(CN[key], row)
self.chk_selected = QCheckBox("显示选中行(预览)")
self.chk_selected.toggled.connect(self.on_toggle_selected)
form.addRow(self.chk_selected)
btns = QHBoxLayout()
b_reset = QPushButton("重置默认")
b_reset.clicked.connect(self.reset)
b_ok = QPushButton("✅ 确定(写入日志并退出)")
b_ok.setStyleSheet("font-weight: bold;")
b_ok.clicked.connect(self.finish)
btns.addWidget(b_reset)
btns.addWidget(b_ok)
form.addRow(btns)
self.win.resize(380, min(self.win.sizeHint().height(), screen.height() - 80))
self.win.move(max(20, self.px - 400), self.py)
self.win.show()
self.apply_all()
# ---------- 启动时给头部标签打角色标签(此时尺寸还是默认值,判定可靠) ----------
def _tag_headers(self):
for g in self.popup._groups:
hwd = self.list.itemWidget(g["header_item"])
for lbl in hwd.findChildren(QLabel):
txt = lbl.text()
if txt == "":
# 默认: 供应商图标 16×16, 箭头 12×12 → 启动时按尺寸区分(此后不再变判定基准)
lbl.setProperty("_role", "icon" if lbl.size().width() >= 14 else "chevron")
elif txt.isdigit():
lbl.setProperty("_role", "count")
else:
lbl.setProperty("_role", "name")
# ---------- 实时渲染 ----------
def on_mark(self, k, on):
if on:
self.broken.add(k)
else:
self.broken.discard(k)
def on_change(self, k, v):
self.params[k] = v
self.apply_all()
def _rebuild_icons(self):
p = self.params
pop = self.popup
pop._provider_pixmap = QPixmap(_popup_svg_path(p.get("provider_svg", "provider.svg"))).scaled(
p["header_icon"], p["header_icon"],
Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
pop._arrow_expanded = QIcon(_popup_svg_path("chevron_down.svg")).pixmap(p["arrow_size"], p["arrow_size"])
pop._arrow_collapsed = QIcon(_popup_svg_path("chevron_right.svg")).pixmap(p["arrow_size"], p["arrow_size"])
m = QPixmap(_popup_svg_path("model.svg")).scaled(
p["model_icon"], p["model_icon"],
Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
pad_w = p["model_icon"] + p["model_icon_pad"]
_pad = QPixmap(pad_w, p["model_icon"])
_pad.fill(Qt.GlobalColor.transparent)
_pp = QPainter(_pad)
_pp.drawPixmap(p["model_icon_pad"], 0, m)
_pp.end()
pop._model_icon = QIcon(_pad)
# 关键:item 持有 QIcon 的拷贝,类属性替换后必须逐个 setIcon 才会更新(实测)
for g in pop._groups:
for it in g["model_items"]:
it.setIcon(pop._model_icon)
self.list.setIconSize(QSize(pad_w, p["model_icon"]))
def apply_all(self):
p = self.params
pop = self.popup
family = p["font_family"]
stack = f'"{family}", "Microsoft YaHei", "Noto Sans SC", sans-serif'
pop.setFixedWidth(p["popup_width"])
pop.container_layout.setContentsMargins(0, p["container_vmargin"], 0, p["container_vmargin"])
self._rebuild_icons()
for g in pop._groups:
hwd = self.list.itemWidget(g["header_item"])
hwd.setFixedHeight(p["header_height"])
g["header_item"].setSizeHint(QSize(0, p["header_height"])) # 行高 = widget 高
btns = hwd.findChildren(QPushButton)
if btns:
bl = btns[0].layout()
if bl is not None:
bl.setContentsMargins(p["header_hpad_l"], 0, 8, 0)
for lbl in hwd.findChildren(QLabel):
role = lbl.property("_role")
if role == "icon":
pad = p["header_icon_pad"]
pv = p["header_icon_pad_v"]
# 2 倍 margin:抵消 label 行内居中吸收的一半增高 → 1:1 位移
top, bot = 2 * max(pv, 0), 2 * max(-pv, 0)
lbl.setFixedSize(p["header_icon"] + pad, p["header_icon"] + top + bot)
lbl.setContentsMargins(pad, top, 0, bot)
lbl.setPixmap(pop._provider_pixmap)
elif role == "chevron":
lbl.setFixedSize(p["arrow_size"], p["arrow_size"])
lbl.setPixmap(pop._arrow_expanded if g["expanded"] else pop._arrow_collapsed)
elif role == "name":
lbl.setStyleSheet(
f"font-family: {stack}; color: #555555; font-weight: normal;"
f"font-size: {p['header_font']}px; letter-spacing: 1px; background: transparent;")
elif role == "count":
lbl.setStyleSheet(
f"font-family: {stack}; color: #888888;"
f"font-size: {p['count_font']}px; font-weight: normal; background: transparent;")
for it in g["model_items"]:
it.setSizeHint(QSize(0, p["model_item_h"]))
pop.setStyleSheet(QSS_TMPL
.replace("FONT_STACK", stack))
# 模型行字体:widget 级 setFontQSS ::item 的 font-size/缩写都不可靠,实测)
_f = self.list.font()
_f.setPixelSize(p["item_font"])
_f.setFamily(family)
self.list.setFont(_f)
self.app.processEvents()
pop.adjust_popup_height()
pop.adjustSize()
def on_toggle_selected(self, on):
if on:
for g in self.popup._groups:
if g["model_items"] and not g["model_items"][0].isHidden():
self.list.setCurrentItem(g["model_items"][0])
break
else:
self.list.clearSelection()
def reset(self):
self.params = dict(DEFAULTS)
for k, sp in self.spins.items():
sp.blockSignals(True)
sp.setValue(self.params[k])
sp.blockSignals(False)
for k, cb in self.combos.items():
cb.blockSignals(True)
cb.setCurrentText(self.params[k])
cb.blockSignals(False)
self.apply_all()
# ---------- 确定 → 写日志 ----------
def finish(self):
log = os.path.join(os.path.dirname(__file__), "..", "data", "model_popup_tune.json")
os.makedirs(os.path.dirname(log), exist_ok=True)
payload = dict(self.params)
payload["not_working"] = sorted(self.broken) # 用户标记的不生效参数
with open(log, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print("TUNE_LOG=" + os.path.abspath(log), flush=True)
print(json.dumps(payload, ensure_ascii=False), flush=True)
self.app.quit()
def main():
try:
Tuner()
QApplication.instance().exec()
except Exception:
err = traceback.format_exc()
try:
with open(os.path.join(os.path.dirname(__file__), "..", "data",
"tune_error.log"), "w", encoding="utf-8") as f:
f.write(err)
except OSError:
pass
QMessageBox.critical(None, "调参工具启动失败", err)
sys.exit(1)
if __name__ == "__main__":
main()
+151
View File
@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
"""公式渲染端到端验证:真实会话消息 → 真实前端管线 → DOM 断言
运行: PYTHONIOENCODING=utf-8 QT_QPA_PLATFORM=offscreen python tests/verify_math_render.py
铁律不污染真实 DB 先把 data/chat_history.db 复制到临时文件再指向副本
"""
import os
import sys
import json
import time
import shutil
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
_REAL_DB = os.path.join(os.path.dirname(__file__), "..", "data", "chat_history.db")
_TMP_DB = os.path.join(tempfile.gettempdir(), f"haocode_verify_math_{os.getpid()}.db")
shutil.copyfile(_REAL_DB, _TMP_DB)
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = _TMP_DB
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
results = []
win = {"w": None, "sid": None}
JS_QUERY = r"""(function(){
var scope = document.getElementById('chat-container') || document.body;
var q = function(s){ return scope.querySelectorAll(s).length; };
// 只取助手消息user 消息按设计是纯文本本来就不渲染公式
var asst = scope.querySelectorAll('.message-wrapper.assistant');
var asstScope = asst.length ? asst[asst.length - 1] : scope;
// 可见文本剔除 katex-mathmlCSS 视觉隐藏的 MathML 内部保留原始 tex 属正常
var clone = asstScope.cloneNode(true);
clone.querySelectorAll('.katex-mathml').forEach(function(e){ e.parentNode.removeChild(e); });
var visTxt = clone.textContent || '';
var codeTxt = '';
asstScope.querySelectorAll('pre, code').forEach(function(e){ codeTxt += e.textContent + '\n'; });
return {
katex: q('.katex'),
display: q('.katex-display'),
mathml: q('.katex-mathml'),
vislayer: q('.katex-html'),
leakedPlaceholder: visTxt.indexOf('@@K') !== -1,
rawBackslash: visTxt.indexOf('\\operatorname') !== -1,
bareBracketFormula: visTxt.indexOf('P_4=\\operatorname') !== -1,
codeHasDollar: codeTxt.indexOf('$x + y$') !== -1,
codeHasArr: codeTxt.indexOf('arr[0]') !== -1
};
})()"""
def check(name, cond, extra=""):
print((" PASS " if cond else " FAIL ") + name + ((" | " + str(extra)) if extra else ""))
results.append(bool(cond))
def run_js(js, timeout_s=15):
r = {"v": None, "d": False}
def cb(val):
if isinstance(val, str):
try:
val = json.loads(val)
except Exception:
pass
r["v"] = val
r["d"] = True
b = win["w"].browser
if hasattr(b, "execute_js_async"):
b.execute_js_async(js, cb)
else:
b.page().runJavaScript(js, cb)
t0 = time.time()
while not r["d"] and time.time() - t0 < timeout_s:
app.processEvents()
time.sleep(0.05)
return r["v"]
def boot():
w = MainWindow()
win["w"] = w
sid = None
for s in w.db.get_all_sessions():
if s.get("title") == "公式渲染验收":
sid = s["id"]
break
if not sid:
print("FAIL 未找到「公式渲染验收」会话(先跑 tests/inject_math_demo.py")
app.quit()
return
win["sid"] = sid
print(f"会话: {sid}")
w.load_messages_to_web(sid)
QTimer.singleShot(9000, phase_check)
def phase_check():
for _ in range(40):
if run_js("window.jsReady === true ? 1 : 0", timeout_s=3) == 1:
break
time.sleep(0.5)
r = run_js(JS_QUERY)
if not isinstance(r, dict):
print(f"FAIL DOM 查询失败: {r!r}")
app.quit()
return
print("\n DOM 统计: " + json.dumps(r, ensure_ascii=False))
check("KaTeX 渲染出公式(.katex > 0", r["katex"] > 0, f"katex={r['katex']}")
check("块公式 7 个(.katex-display == 7", r["display"] == 7, f"display={r['display']}")
check("可视层存在(.katex-html > 0", r["vislayer"] > 0, f"vislayer={r['vislayer']}")
check("无占位符泄漏", not r["leakedPlaceholder"])
check("可见文本无原始 tex 残留", not r["rawBackslash"])
check("无裸括号公式残留", not r["bareBracketFormula"])
check("代码块内 $x + y$ 保持原样", r["codeHasDollar"])
check("代码块内 arr[0] 保持原样", r["codeHasArr"])
try:
shot_path = os.path.join(os.path.dirname(__file__), "_tmp_math_render.png")
win["w"].browser.grab().save(shot_path)
print(f" 截图: {shot_path}")
except Exception as e:
print(f" 截图失败: {e}")
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====")
try:
win["w"].close()
except Exception:
pass
app.quit()
QTimer.singleShot(400, boot)
QTimer.singleShot(120000, app.quit)
app.exec()
try:
os.remove(_TMP_DB)
except Exception:
pass
sys.exit(0 if all(results) else 1)
+84
View File
@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""on-screen 布局验证:正文 .md-segment 在真实窗口中的 offsetHeight。
offscreen 没有布局一切 h=0必须在真实窗口验证
PASS 条件流式中 + finish 正文段 h > 0
"""
import os, sys
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt6.QtWidgets import QApplication
import PyQt6.QtWebEngineWidgets # noqa
from PyQt6.QtCore import QTimer
from ui.views.main_window import MainWindow
app = QApplication(sys.argv)
w = MainWindow()
w.resize(1000, 700)
w.show()
AUDIT = {}
def js(expr, cb=None):
if cb:
w.browser.page().runJavaScript(expr, cb)
else:
w.browser.page().runJavaScript(expr)
def on_ready(_res):
js(f"createMessage('vtest', 'assistant');")
QTimer.singleShot(300, phase_stream)
def phase_stream():
js("appendReasoning('vtest', '这是思考第一段内容。');")
body = "测试!测试!测试!\n\n这是第二段正文,用于验证布局高度。"
import json as _json
for ch in body:
js("appendToken('vtest', " + _json.dumps(ch) + ");")
QTimer.singleShot(1500, phase_mid_check)
def phase_mid_check(_r=None):
js("""(function(){
var seg = document.querySelector('#vtest .md-segment');
var tc = document.querySelector('#vtest .think-content');
return JSON.stringify({
mid: true,
segH: seg ? seg.offsetHeight : -1,
segConnected: seg ? seg.isConnected : null,
segRect: seg ? Math.round(seg.getBoundingClientRect().height) : -1,
tcH: tc ? tc.offsetHeight : -1
});
})()""", on_mid)
def on_mid(res):
AUDIT["mid"] = res
js("finishMessage('vtest');")
QTimer.singleShot(1200, phase_finish_check)
def phase_finish_check(_r=None):
js("""(function(){
var seg = document.querySelector('#vtest .md-segment');
return JSON.stringify({
fin: true,
segH: seg ? seg.offsetHeight : -1,
segText: seg ? seg.textContent.length : -1,
segDisplay: seg ? getComputedStyle(seg).display : null
});
})()""", on_finish)
def on_finish(res):
AUDIT["fin"] = res
print("MID =", AUDIT.get("mid"))
print("FIN =", AUDIT.get("fin"))
try:
import json
m, f = json.loads(AUDIT["mid"]), json.loads(AUDIT["fin"])
ok = m["segH"] > 0 and m["tcH"] > 0 and f["segH"] > 0 and f["segText"] > 20
print("===== " + ("PASS: 正文段真实布局高度正常" if ok else "FAIL: 正文段高度异常") + f" mid.segH={m['segH']} fin.segH={f['segH']} =====")
except Exception as e:
print("===== FAIL: 解析异常", e, "=====")
app.quit()
QTimer.singleShot(2500, lambda: js("document.readyState", on_ready))
QTimer.singleShot(30000, app.quit)
app.exec()
View File
View File
+161
View File
@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
"""
PDF 读取工具 (pdf_reader)
=========================
项目内第一个正式内置工具builtin tool对外提供两项能力
1. ``extract_pdf_text`` 提取 PDF 全部文本按页结构化为提示词友好格式
2. ``extract_pdf_images`` 提取 PDF 内嵌的所有图片逐张落地为 PNG
代码规范与调用模式约定
--------------------------
* 每个对外函数都带完整类型注解参数 + 返回值
* docstring 采用 ``Args / Returns / Raises`` 分段逐参数说明含义与单位
* 将来的工具注册器tools/registry.py尚未实现可通过
``inspect.signature`` + ``__doc__`` 自动反射出 OpenAI 格式的 Tool JSON Schema
因此本文件的函数签名与文档即"接口契约"请保持稳定描述清晰
当前阶段说明
------------
* 本工具暂**不接入** agent 的自动工具调用链路registry 未实现
UI 附件流程``ui/views/main_window.py`` PDF 处理直接 import 调用
* 参数与返回值已按"可被自动调用"的标准描述清楚后续接线无需改动函数本身
依赖与协议
----------
* 依赖 PyMuPDF导入名 ``pymupdf``
* PyMuPDF **AGPL-3.0 / Artifex 商业** 双许可分发或通过网络提供服务时
需按 AGPL 开源本项目或购买商业许可纯自用/内部不分发则无约束
"""
import os
import uuid
try:
import pymupdf # PyMuPDF >= 1.24 的官方导入名
except ImportError: # 兼容旧版导入名 fitz
import fitz as pymupdf # type: ignore
# 单个 PDF 的大小上限(字节):超过则拒绝解析,避免内存与上下文爆炸
PDF_MAX_BYTES = 20 * 1024 * 1024 # 20 MB
def _open_doc(path: str, max_bytes: int) -> "pymupdf.Document":
"""内部辅助:做存在性/大小校验后打开 PDF,统一异常为 ValueError。"""
if not os.path.isfile(path):
raise ValueError("文件不存在")
size = os.path.getsize(path)
if size > max_bytes:
raise ValueError(
f"PDF 过大({size / 1024 / 1024:.1f} MB > {max_bytes / 1024 / 1024:.0f} MB"
)
try:
doc = pymupdf.open(path)
except Exception as e: # pymupdf 抛出的异常类型不固定,统一兜底
raise ValueError(f"PDF 打开失败(可能损坏): {e}")
if doc.needs_pass:
doc.close()
raise ValueError("PDF 已加密,需要密码,暂不支持")
return doc
def extract_pdf_text(path: str, max_bytes: int = PDF_MAX_BYTES) -> tuple:
"""提取 PDF 的全部文本,按页结构化。
输出格式无文本的空页会被跳过::
[ 1 ]
<该页文本>
[ 2 ]
<该页文本>
Args:
path: PDF 文件路径绝对路径或相对当前工作目录的路径
max_bytes: 允许解析的最大文件字节数默认 ``PDF_MAX_BYTES``20MB
Returns:
``(structured_text, page_count)`` 二元组
* ``structured_text`` (str): 按页拼接的结构化文本各页之间以空行分隔
若整份 PDF 无文本则为空字符串 ``""``
* ``page_count`` (int): PDF 的总页数含空页
Raises:
ValueError: 文件不存在 / 超过 ``max_bytes`` / PDF 损坏 / PDF 加密
"""
doc = _open_doc(path, max_bytes)
try:
page_count = doc.page_count
parts = []
for i, page in enumerate(doc, 1):
text = page.get_text("text").strip()
if text:
parts.append(f"[第 {i} 页]\n{text}")
return "\n\n".join(parts), page_count
finally:
doc.close()
def extract_pdf_images(path: str, out_dir: str, max_bytes: int = PDF_MAX_BYTES) -> list:
"""提取 PDF 内嵌的所有图片,逐张保存为 PNG。
遍历每一页的内嵌图片``page.get_images`` ``Pixmap`` 解码后写出
CMYK 色彩空间会自动转换为 RGB否则无法存为 PNG单张图片解码失败
会被跳过不影响其余图片
Args:
path: PDF 文件路径
out_dir: 图片输出目录建议使用绝对路径不存在时自动创建
max_bytes: 允许解析的最大文件字节数默认 ``PDF_MAX_BYTES``20MB
Returns:
图片元数据列表 ``(page, index)`` 升序每项为 dict::
{
"page": int, # 所在页码(从 1 开始)
"index": int, # 该页内的第几张(从 1 开始)
"abs_path": str, # 落地 PNG 的绝对路径
"mime": str, # 固定为 "image/png"
"size_kb": float, # 文件大小(KB,保留两位小数)
"width": int, # 图片像素宽
"height": int, # 图片像素高
}
PDF 中无内嵌图片时返回空列表 ``[]``
Raises:
ValueError: 文件不存在 / 超过 ``max_bytes`` / PDF 损坏 / PDF 加密
"""
doc = _open_doc(path, max_bytes)
os.makedirs(out_dir, exist_ok=True)
tag = uuid.uuid4().hex[:8] # 本批次文件名前缀,避免跨 PDF 撞名
results = []
try:
for page_num, page in enumerate(doc, 1):
for img_index, img_info in enumerate(page.get_images(full=True), 1):
xref = img_info[0]
try:
pix = pymupdf.Pixmap(doc, xref)
if pix.n - pix.alpha >= 4: # CMYK → RGB
pix = pymupdf.Pixmap(pymupdf.csRGB, pix)
fname = f"pdfimg_{tag}_p{page_num}_{img_index}.png"
abs_path = os.path.join(out_dir, fname)
pix.save(abs_path)
width, height = pix.width, pix.height
pix = None # 及时释放位图内存
except Exception:
continue # 单张失败不影响整体
results.append({
"page": page_num,
"index": img_index,
"abs_path": abs_path,
"mime": "image/png",
"size_kb": round(os.path.getsize(abs_path) / 1024, 2),
"width": width,
"height": height,
})
return results
finally:
doc.close()
View File
+944
View File
@@ -0,0 +1,944 @@
# -*- coding: utf-8 -*-
"""右侧任务面板:本次会话的 bash 任务监控(运行中 / 已完成)
统一口径与用户确认
· 两栏 = 运行中/已完成按状态分两栏同时可见不互斥
中间是可拖动分隔线260px 宽若真做左右并排每栏仅 ~130px
展开区放不下参数 + 输出两块列表故用上下两栏
若要严格左右并排 _SPLIT_ORIENTATION 改成 Horizontal 即可
· 一次 bash = 一层BashLayer自上而下按执行先后排列
· 单击层头 = 展开/收起展开后分参数输出两块
· 运行中输出为 tool_bash 的实时流缓冲上限 200KB超出丢弃最旧并标注
· 已完成输出为进入上下文的原文DB messages.timeline[].result不截断
· 范围永远是当前会话切换会话跟随刷新
· 面板不自动收缩也不自动展开开关就在面板自身
收起态 = 栏正中按钮展开态 = 标题行右上角按钮永远只有一个可动按钮
· 🆕 左边缘可拖拽调宽最小 200px松手立即记录到 config.json["bash_panel_width"]
下次按展开按钮自动恢复到记录的宽度
· 已出上下文= 该层在最近一次压缩切点之前其输出已不在 API 上下文里
"""
import json
import os
import re
from PyQt6 import QtCore, QtGui, QtWidgets
LIVE_BUF_CAP = 200 * 1024 # 每条实时输出缓冲上限
LAYER_LIMIT = 30 # 已完成栏默认只渲染最近 N 层
_SPLIT_ORIENTATION = QtCore.Qt.Orientation.Vertical # 改 Horizontal = 左右并排
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_DUR_RE = re.compile(r"\((\d+(?:\.\d+)?)s\)\s*$")
# ---------------------------------------------------------------- 宽度
PANEL_W_DEFAULT = 260 # 首次运行的默认展开宽度
PANEL_W_MIN = 200 # 🆕 拖拽最小宽度(再窄「参数/输出」两块就挤坏了)
PANEL_W_MAX = 560 # 绝对上限(实际还会受「不超过主窗口 50%」约束)
_CFG_KEY = "bash_panel_width"
_CFG_PATH = os.path.join(_ROOT, "data", "config.json")
def _cfg_path() -> str:
"""配置文件路径(测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)"""
return os.environ.get("HAOCODE_CONFIG_FILE") or _CFG_PATH
def load_panel_width(default: int = PANEL_W_DEFAULT) -> int:
"""读回上次拖拽记录的宽度;缺失/异常 → 默认值(并强制夹在 [MIN, MAX])"""
try:
with open(_cfg_path(), "r", encoding="utf-8") as f:
w = int(json.load(f).get(_CFG_KEY, default))
except Exception:
return default
return max(PANEL_W_MIN, min(PANEL_W_MAX, w))
def save_panel_width(w: int) -> bool:
"""只改这一个键、其余配置原样保留;临时文件 + replace 原子写"""
p = _cfg_path()
try:
cfg = {}
if os.path.exists(p):
with open(p, "r", encoding="utf-8") as f:
cfg = json.load(f) or {}
cfg[_CFG_KEY] = int(w)
tmp = p + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
os.replace(tmp, p)
return True
except Exception as e:
print(f"[UI]: 保存任务面板宽度失败: {e}")
return False
def _svg(name: str) -> str:
return os.path.join(_ROOT, "svg", name)
def _fmt_dur(sec) -> str:
try:
sec = float(sec)
except Exception:
return ""
return f"{sec:.1f}s" if sec < 60 else f"{int(sec // 60)}m{int(sec % 60)}s"
def _parse_dur(text):
"""从上下文文本结尾的 (Ns) 里取耗时;取不到返回 None"""
m = _DUR_RE.search((text or "").strip())
if not m:
return None
try:
return float(m.group(1))
except Exception:
return None
def _cmd_of(args) -> str:
"""args 可能是 JSON 字符串或 dict → 取出 bash 的 command"""
if isinstance(args, dict):
return args.get("command", "") or ""
try:
d = json.loads(args or "{}")
if isinstance(d, dict):
return d.get("command", "") or ""
except Exception:
pass
return str(args or "")
def _iter_bash_entries(timeline):
"""timeline 可能是 JSON 字符串(DB)或 list[dict](内存)→ 产出 name=='bash' 的工具条目"""
data = timeline
if isinstance(timeline, str):
if not timeline.strip():
return
try:
data = json.loads(timeline)
except Exception:
return
if not isinstance(data, list):
return
for e in data:
if isinstance(e, dict) and e.get("t") == "tool" and e.get("name") == "bash":
yield e
class _ClickFrame(QtWidgets.QFrame):
"""整块可单击(单击即触发,不用双击)"""
clicked = QtCore.pyqtSignal()
def mouseReleaseEvent(self, e):
if (e.button() == QtCore.Qt.MouseButton.LeftButton
and self.rect().contains(e.position().toPoint())):
self.clicked.emit()
super().mouseReleaseEvent(e)
# ======================================================================
# 一层 = 一次 bash 执行
# ======================================================================
class BashLayer(QtWidgets.QFrame):
def __init__(self, call_id: str, command: str = "", parent=None):
super().__init__(parent)
self.setObjectName("bl_layer")
self.call_id = call_id
self.command = command or ""
self.ok = None # None=运行中 / True / False=已完成
self.in_context = True
self.expanded = False
self.elapsed = 0
self.timeout = 0
self._live = ""
self._live_truncated = False
self._final = None
self._build()
self._apply_status()
# ---------------- UI ----------------
def _build(self):
v = QtWidgets.QVBoxLayout(self)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
self.head = _ClickFrame()
self.head.setObjectName("bl_head")
self.head.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.head.setToolTip(self.command or "")
hl = QtWidgets.QHBoxLayout(self.head)
hl.setContentsMargins(8, 6, 8, 6)
hl.setSpacing(6)
self.dot = QtWidgets.QLabel("")
self.dot.setObjectName("bl_dot")
self.name = QtWidgets.QLabel("bash")
self.name.setObjectName("bl_name")
self.meta = QtWidgets.QLabel("")
self.meta.setObjectName("bl_meta")
self.tag = QtWidgets.QLabel("")
self.tag.setObjectName("bl_tag")
self.cmd = QtWidgets.QLabel(self._preview())
self.cmd.setObjectName("bl_cmd")
self.cmd.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored,
QtWidgets.QSizePolicy.Policy.Preferred)
self.chev = QtWidgets.QLabel("")
self.chev.setObjectName("bl_chev")
hl.addWidget(self.dot, 0)
hl.addWidget(self.name, 0)
hl.addWidget(self.meta, 0)
hl.addWidget(self.tag, 0)
hl.addWidget(self.cmd, 1)
hl.addWidget(self.chev, 0)
v.addWidget(self.head)
self.body = QtWidgets.QWidget()
self.body.setObjectName("bl_body")
bl = QtWidgets.QVBoxLayout(self.body)
bl.setContentsMargins(8, 0, 8, 8)
bl.setSpacing(4)
self.lbl_arg = QtWidgets.QLabel("参数")
self.lbl_arg.setObjectName("bl_sect_label")
self.arg_box = self._code_box(80, wrap=True)
bl.addWidget(self.lbl_arg)
bl.addWidget(self.arg_box)
self.lbl_out = QtWidgets.QLabel("输出")
self.lbl_out.setObjectName("bl_sect_label")
self.out_box = self._code_box(230, wrap=False)
bl.addWidget(self.lbl_out)
bl.addWidget(self.out_box)
self.body.hide()
v.addWidget(self.body)
self.head.clicked.connect(self.toggle)
def _code_box(self, max_h, wrap):
box = QtWidgets.QPlainTextEdit()
box.setObjectName("bl_code")
box.setReadOnly(True)
box.setMaximumHeight(max_h)
box.setLineWrapMode(
QtWidgets.QPlainTextEdit.LineWrapMode.WidgetWidth if wrap
else QtWidgets.QPlainTextEdit.LineWrapMode.NoWrap)
box.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
box.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
box.setHorizontalScrollBarPolicy(
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded if not wrap
else QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
return box
def _preview(self) -> str:
s = " ".join((self.command or "").split())
return s[:58] + ("" if len(s) > 58 else "")
# ---------------- 状态 ----------------
def _apply_status(self):
if self.ok is None:
self.dot.setProperty("state", "run")
self.meta.setText(f"{self.elapsed}/{self.timeout}s" if self.timeout
else (f"{self.elapsed}s" if self.elapsed else ""))
else:
self.dot.setProperty("state", "ok" if self.ok else "bad")
self.meta.setText(_fmt_dur(self.elapsed) if self.elapsed else "")
if self.ok is not None and not self.in_context:
self.tag.setText("已出上下文")
self.tag.setProperty("kind", "out")
elif self.ok is None:
self.tag.setText("运行中")
self.tag.setProperty("kind", "run")
elif self.ok:
self.tag.setText("已完成")
self.tag.setProperty("kind", "ok")
else:
self.tag.setText("失败")
self.tag.setProperty("kind", "bad")
for w in (self.dot, self.tag):
w.style().unpolish(w)
w.style().polish(w)
def set_running(self, elapsed, timeout):
self.ok = None
self.elapsed = int(elapsed or 0)
self.timeout = int(timeout or 0)
self._apply_status()
if self.expanded:
self._render_output()
def append_live(self, text: str):
if not text:
return
self._live += text
if len(self._live) > LIVE_BUF_CAP:
self._live = self._live[-LIVE_BUF_CAP:]
self._live_truncated = True
if self.expanded:
self._render_output()
def set_finished(self, ok, text):
self.ok = bool(ok)
self._final = text or ""
d = _parse_dur(self._final)
if d is not None:
self.elapsed = d
self._apply_status()
if self.expanded:
self._render_output()
def set_in_context(self, in_ctx: bool):
self.in_context = bool(in_ctx)
self._apply_status()
# ---------------- 展开 ----------------
def toggle(self):
self.expanded = not self.expanded
self.body.setVisible(self.expanded)
self.chev.setText("" if self.expanded else "")
if self.expanded:
self.arg_box.setPlainText(self.command or "(无参数)")
self._render_output()
def _render_output(self):
if self.ok is None:
body = self._live
if self._live_truncated:
body = "…(实时缓冲已截断,仅保留最后 200KB)\n" + body
self.lbl_out.setText("输出(实时)")
self.out_box.setPlainText(body if body else "(等待输出…)")
sb = self.out_box.verticalScrollBar()
sb.setValue(sb.maximum())
else:
self.lbl_out.setText("输出(进入上下文)")
self.out_box.setPlainText(self._final or "(无输出)")
# ======================================================================
# 一栏:标题(可折叠)+ 层列表
# ======================================================================
class _Section(QtWidgets.QWidget):
def __init__(self, title, empty_hint, on_fold=None, parent=None):
super().__init__(parent)
self.empty_hint = empty_hint
self.folded = False
self._on_fold = on_fold # 回调:交面板做高度动画(抽屉式)
self._effect = None # 内容区淡入淡出用(按需创建,静止时移除)
self._head_hint = 0 # 头部行高缓存(恒定值)
v = QtWidgets.QVBoxLayout(self)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
self.head = _ClickFrame()
self.head.setObjectName("bl_sect_head")
self.head.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
hl = QtWidgets.QHBoxLayout(self.head)
hl.setContentsMargins(10, 6, 8, 6)
hl.setSpacing(6)
self.chev = QtWidgets.QLabel("")
self.chev.setObjectName("bl_sect_chev")
self.title = QtWidgets.QLabel(title)
self.title.setObjectName("bl_sect_title")
self.count = QtWidgets.QLabel("0")
self.count.setObjectName("bl_sect_count")
hl.addWidget(self.chev, 0)
hl.addWidget(self.title, 0)
hl.addStretch(1)
hl.addWidget(self.count, 0)
v.addWidget(self.head)
self.scroll = QtWidgets.QScrollArea()
self.scroll.setObjectName("bl_scroll")
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# 🐛 修复:QScrollArea 的 viewport 默认按 palette base(白)自画背景,
# 会把父级 #f7f8fa 盖成白色 → 强制透明
self.scroll.viewport().setStyleSheet("background: transparent;")
self.scroll.viewport().setAutoFillBackground(False)
self.host = QtWidgets.QWidget()
self.host.setObjectName("bl_host")
self.host.setAutoFillBackground(False)
self.lay = QtWidgets.QVBoxLayout(self.host)
self.lay.setContentsMargins(6, 2, 6, 6)
self.lay.setSpacing(5)
self.scroll.setWidget(self.host)
# 🆕 「下面填充的 bash 层」整块放进 body:折叠/展开动画只针对它,
# 头部行(self.head)高度永远不变
self.body = QtWidgets.QWidget()
self.body.setObjectName("bl_body")
self.body.setAutoFillBackground(False)
bv = QtWidgets.QVBoxLayout(self.body)
bv.setContentsMargins(0, 0, 0, 0)
bv.setSpacing(0)
bv.addWidget(self.scroll, 1)
self.hint = QtWidgets.QLabel("")
self.hint.setObjectName("bl_hint")
self.hint.setWordWrap(True)
bv.addWidget(self.hint)
v.addWidget(self.body, 1)
self.head.clicked.connect(self._toggle_fold)
self._set_hint(self.empty_hint)
# 🐛 头部行高【硬固定】:收起时 body 被隐、布局里只剩 head,而 QFrame 默认
# 竖直策略可伸长 → head 会被拉满整栏(实测 793px 巨条 = 「按钮变大很高」)
self._head_hint = max(16, self.head.sizeHint().height())
self.head.setFixedHeight(self._head_hint)
# ---------------- 头部行高(收起/展开恒定的那一行)----------------
def head_height(self) -> int:
# 🐛 必须缓存 sizeHint:若用实时 self.head.height(),会形成正反馈——
# 头部被压矮 → minimumSizeHint 变小 → splitter 允许更矮 → 头部再被压矮…
if self._head_hint <= 0:
self._head_hint = max(16, self.head.sizeHint().height())
return self._head_hint
def minimumSizeHint(self):
# 🐛 关键修复:默认最小高 ≈ 113px(头部 + QScrollArea 最小高 + 提示语),
# QSplitter 完全没法把本栏收缩 → 收起后留一大块空白(用户报的 bug)。
# 改为「只剩头部行」,内容区允许被压到 0(超出部分天然被父级裁切)。
return QtCore.QSize(0, self.head_height())
# ---------------- 内容区透明度(动画帧驱动)----------------
def set_body_opacity(self, op: float):
op = max(0.0, min(1.0, float(op)))
if op >= 0.999:
self._drop_effect()
return
if self._effect is None:
self._effect = QtWidgets.QGraphicsOpacityEffect(self.body)
self.body.setGraphicsEffect(self._effect)
self._effect.setOpacity(op)
def _drop_effect(self):
"""去掉离屏渲染开销(静止时不挂 effect)"""
if self.body.graphicsEffect() is not None:
self.body.setGraphicsEffect(None)
self._effect = None
def show_body(self):
self.body.setVisible(True)
self.scroll.setVisible(True)
self.hint.setVisible(bool(self.hint.text()))
def hide_body(self):
self.body.setVisible(False)
def set_folded(self, folded: bool):
"""应用折叠状态(高度由面板的 splitter 动画负责)"""
self.folded = bool(folded)
self.chev.setText("" if self.folded else "")
if self.folded:
self._drop_effect()
else:
self.show_body()
self.set_body_opacity(1.0)
def _toggle_fold(self):
if self._on_fold is not None:
self._on_fold(self) # 交面板:状态切换 + 高度抽屉动画
return
self.set_folded(not self.folded)
self.scroll.setVisible(not self.folded)
self.hint.setVisible(not self.folded and bool(self.hint.text()))
def _set_hint(self, text):
self.hint.setText(text or "")
self.hint.setVisible(not self.folded and bool(text))
def set_layers(self, layers, total=None):
"""重排层(复用同一批 widget → 实时缓冲不丢)"""
while self.lay.count():
it = self.lay.takeAt(0)
w = it.widget()
if w is not None:
w.setParent(None)
for w in layers:
self.lay.addWidget(w)
w.show()
self.lay.addStretch(1)
self.count.setText(str(len(layers)))
total = len(layers) if total is None else total
if total > len(layers):
self._set_hint(f"仅显示最近 {len(layers)} 层(本会话共 {total} 层)")
else:
self._set_hint("" if layers else self.empty_hint)
# ======================================================================
# 右侧任务面板(外壳,镜像左侧边栏)
# ======================================================================
class _ResizeHandle(QtWidgets.QWidget):
"""面板左边缘的拖拽手柄(4px 隐形条,hover 才亮;光标 SizeHor"""
W = 4
def __init__(self, panel):
super().__init__(panel)
self._panel = panel
self.setObjectName("bl_resize_handle")
# QWidget 子类必须显式开启才会绘制样式表背景(hover 高亮靠它)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
self.setCursor(QtCore.Qt.CursorShape.SizeHorCursor)
self.setStyleSheet("#bl_resize_handle:hover { background: #dfe3ea; }")
self.setToolTip("拖拽调整任务面板宽度(最小 %dpx" % PANEL_W_MIN)
self._drag = False
def mousePressEvent(self, e):
if e.button() == QtCore.Qt.MouseButton.LeftButton:
self._drag = True
self._panel._drag_begin(e.globalPosition().x())
e.accept()
return
super().mousePressEvent(e)
def mouseMoveEvent(self, e):
if self._drag:
self._panel._drag_to_global_x(e.globalPosition().x())
e.accept()
return
super().mouseMoveEvent(e)
def mouseReleaseEvent(self, e):
if self._drag:
self._drag = False
self._panel._commit_drag_width()
e.accept()
return
super().mouseReleaseEvent(e)
class BashPanel(QtWidgets.QWidget):
W_EXPAND = PANEL_W_DEFAULT
W_COLLAPSE = 52
W_MIN = PANEL_W_MIN
W_MAX = PANEL_W_MAX
FOLD_MS = 200 # 栏折叠/展开时长(与 ModelSelectPopup.DRAWER_DURATION 一致)
FOLD_STEP = 16 # 帧间隔(与 DRAWER_STEP 一致,~60fps
MIN_BODY_H = 60 # 展开后「下面填充的层」至少保留的高度
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("right_sidebar")
# 🐛 关键修复:QWidget 的自定义子类默认【不绘制样式表背景/边框】
# #sidebar 是原生 QWidget 实例所以正常,BashPanel 是子类所以失效——
# 表现为右侧栏底色变白、左侧分隔线丢失、空间感消失)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
# 🆕 展开宽度:优先用上次拖拽记录的值(config.json: bash_panel_width
self.W_EXPAND = load_panel_width()
self._end_w = self.W_EXPAND
self._drag_right = None
self._w = self.W_COLLAPSE
self.setFixedWidth(self._w) # 默认收起
self.collapsed = True
self._target = True
self._anim = None
self._session_id = None
self._db = None
self._layers = {} # call_id -> BashLayer
self._order = [] # 执行先后
self._running = set()
self._done = set()
self._saved_h = [120, 520] # 每栏收起前的高度(展开时恢复)
self._fold = None # 折叠动画状态
self._fold_timer = QtCore.QTimer(self)
self._fold_timer.setInterval(self.FOLD_STEP)
self._fold_timer.timeout.connect(self._fold_tick)
self._build()
# 🆕 左边缘拖拽手柄(浮在最左 4px,收起/展开两种状态都在)
self._handle = _ResizeHandle(self)
self._handle.setFixedWidth(_ResizeHandle.W)
self._handle.raise_()
# ---------------- 外壳 ----------------
def _build(self):
v = QtWidgets.QVBoxLayout(self)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
self.stack = QtWidgets.QStackedWidget()
v.addWidget(self.stack)
# ---- 展开页(顶部留白 20px + 36px 标题行,与左侧栏完全对齐)----
self.expand_page = QtWidgets.QWidget()
ev = QtWidgets.QVBoxLayout(self.expand_page)
ev.setContentsMargins(0, 20, 0, 12)
ev.setSpacing(0)
self.header = QtWidgets.QWidget()
self.header.setObjectName("bl_panel_head")
self.header.setFixedHeight(36)
hl = QtWidgets.QHBoxLayout(self.header)
hl.setContentsMargins(10, 0, 10, 0)
hl.setSpacing(6)
self.lbl_title = QtWidgets.QLabel("任务面板")
self.lbl_title.setObjectName("bl_panel_title")
self.lbl_total = QtWidgets.QLabel("")
self.lbl_total.setObjectName("bl_panel_total")
# 🐛 修复双按钮:面板内不再自带收起按钮(唯一开关 = 顶部工具栏按钮),
# 与左侧栏的区别:左侧栏的开关在栏内,右侧栏的开关在顶部(原「导出」位置)
hl.addWidget(self.lbl_title, 0)
hl.addWidget(self.lbl_total, 0)
hl.addStretch(1)
# 🆕 展开态开关:标题行右端(与左侧栏 collapse_btn 完全同规格)
self.fold_btn = QtWidgets.QPushButton("")
self.fold_btn.setObjectName("collapse_btn")
self.fold_btn.setIcon(QtGui.QIcon(_svg("panel_right.svg")))
self.fold_btn.setIconSize(QtCore.QSize(16, 16))
self.fold_btn.setFixedSize(28, 28)
self.fold_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.fold_btn.setToolTip("收起任务面板")
hl.addWidget(self.fold_btn, 0)
ev.addWidget(self.header)
ev.addSpacing(10)
self.splitter = QtWidgets.QSplitter(_SPLIT_ORIENTATION)
self.splitter.setObjectName("bl_splitter")
self.splitter.setChildrenCollapsible(False)
self.splitter.setHandleWidth(5)
self.sec_running = _Section("运行中", "暂无正在运行的 bash", on_fold=self._toggle_section)
self.sec_done = _Section("已完成", "本会话还没有已完成的 bash", on_fold=self._toggle_section)
self.splitter.addWidget(self.sec_running)
self.splitter.addWidget(self.sec_done)
# 🆕 底部留白占位:最后一栏收起时由它吸收余量(否则栏头会被顶到面板底部,
# 或被拉伸成巨条)
self.spacer = QtWidgets.QWidget()
self.spacer.setObjectName("bl_spacer")
self.spacer.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
self.spacer.setStyleSheet("#bl_spacer { background: transparent; }")
self.splitter.addWidget(self.spacer)
self.splitter.setCollapsible(2, True) # 占位层允许被压到 0
self.splitter.setSizes(self._saved_h + [0])
_h2 = self.splitter.handle(2)
if _h2 is not None: # 占位层上方的分隔条:不可拖、不可见
_h2.setEnabled(False)
_h2.setStyleSheet("background: transparent;")
ev.addWidget(self.splitter, 1)
self.stack.addWidget(self.expand_page)
# ---- 收起页:栏正中一个展开按钮(与左侧栏 collapse_expand_btn 完全同规格)----
self.collapse_page = QtWidgets.QWidget()
cl = QtWidgets.QVBoxLayout(self.collapse_page)
cl.setContentsMargins(0, 0, 0, 0)
cl.addStretch(1)
self.expand_btn = QtWidgets.QPushButton("")
self.expand_btn.setObjectName("collapse_btn")
self.expand_btn.setIcon(QtGui.QIcon(_svg("panel_right.svg")))
self.expand_btn.setIconSize(QtCore.QSize(18, 18))
self.expand_btn.setFixedSize(34, 34)
self.expand_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.expand_btn.setToolTip("展开任务面板")
cl.addWidget(self.expand_btn, 0, QtCore.Qt.AlignmentFlag.AlignHCenter)
cl.addStretch(1)
self.stack.addWidget(self.collapse_page)
# 两个按钮分居 QStackedWidget 两页 → 任何时刻可见开关恒为 1 个
self.stack.setCurrentWidget(self.collapse_page)
self.fold_btn.clicked.connect(self.toggle)
self.expand_btn.clicked.connect(self.toggle)
# ---------------- 两栏折叠/展开:抽屉式高度动画(头部行高恒定)----------------
def _toggle_section(self, sec):
"""点栏头 → 只把「下面填充的 bash 层」的高度收到 0,头部行高度不变"""
self._finish_fold() # 连点:先把上一段动画落定
idx = 0 if sec is self.sec_running else 1
sizes = self.splitter.sizes()
avail = sum(sizes)
head_h = sec.head_height()
other = self.sec_done if idx == 0 else self.sec_running
if not sec.folded and not other.folded:
# 即将收起 + 另一栏展开 → 记住当前高度(用于下次展开恢复);
# 另一栏已收起时不记(此时本栏高度是「余量」而非用户意图高度)
self._saved_h[idx] = max(sizes[idx], head_h + self.MIN_BODY_H)
sec.set_folded(not sec.folded)
target = self._layout_targets(actor=sec)
if not sec.folded: # 展开 → 内容从透明开始淡入
sec.show_body()
sec.set_body_opacity(0.0)
self._sync_handle()
if avail <= 0 or self.splitter.width() <= 0:
self.splitter.setSizes(target)
self._end_fold_visual(sec)
return
self._fold = {"sec": sec, "from": list(sizes), "to": target, "t": 0.0}
self._fold_timer.start()
def _layout_targets(self, actor=None):
"""统一布局策略(层从顶部堆叠):
· 收起栏 = 只剩头部行
· 本次被点开的那栏 用记录的高度
· 余量只由展开着的已完成吸收填满到底部
已完成已收起 余量进底部留白栏头紧跟上方内容
"""
s = self.splitter.sizes()
avail = sum(s)
hr = self.sec_running.head_height()
hd = self.sec_done.head_height()
h0, h1 = s[0], s[1]
run_f, done_f = self.sec_running.folded, self.sec_done.folded
if run_f:
h0 = hr
if done_f:
h1 = hd
if (not run_f) and actor is self.sec_running:
h0 = self._saved_h[0]
if (not done_f) and actor is self.sec_done:
h1 = self._saved_h[1]
if not done_f: # 已完成展开 → 吃掉全部余量
floor0 = hr if run_f else hr + self.MIN_BODY_H
h1 = max(hd + self.MIN_BODY_H, avail - max(h0, floor0))
h0 = max(hr if run_f else hr + self.MIN_BODY_H, h0)
h1 = max(hd if done_f else hd + self.MIN_BODY_H, h1)
return [h0, h1, max(0, avail - h0 - h1)]
def _fold_tick(self):
"""抽屉式每帧:ease-out 三次方插值高度 + 内容淡入淡出(同模型选择窗口)"""
d = self._fold
if d is None:
self._fold_timer.stop()
return
d["t"] = min(1.0, d["t"] + self.FOLD_STEP / self.FOLD_MS)
e = 1.0 - (1.0 - d["t"]) ** 3
a, b = d["from"], d["to"]
self.splitter.setSizes([int(round(x + (y - x) * e)) for x, y in zip(a, b)])
sec = d["sec"]
sec.set_body_opacity((1.0 - e) if sec.folded else e)
if d["t"] >= 1.0:
self._finish_fold()
def _finish_fold(self):
self._fold_timer.stop()
d = self._fold
if d is None:
return
self._fold = None
self.splitter.setSizes(d["to"])
self._end_fold_visual(d["sec"])
def _end_fold_visual(self, sec):
if sec.folded:
sec.set_body_opacity(0.0)
sec.hide_body() # 隐藏后连离屏渲染也省了
else:
sec.show_body()
sec.set_body_opacity(1.0) # 置 1 → 自动移除 effect
def _sync_handle(self):
"""两栏都展开 → 中间分隔条可拖;任一栏收起 → 禁用(避免「刚收起又被拖大」)"""
h = self.splitter.handle(1)
if h is not None:
ok = not (self.sec_running.folded or self.sec_done.folded)
h.setEnabled(ok)
h.setCursor(QtCore.Qt.CursorShape.SplitVCursor if ok
else QtCore.Qt.CursorShape.ArrowCursor)
def _repin_fold(self):
"""窗口/面板尺寸变化后,按统一策略重新钉住(收起栏 = 只剩头部行)"""
if self._fold is not None or self.collapsed:
return
if not (self.sec_running.folded or self.sec_done.folded):
return
want = self._layout_targets()
if want != self.splitter.sizes():
self.splitter.setSizes(want)
def get_panelWidth(self):
return self._w
def set_panelWidth(self, w):
self._w = int(w)
self.setFixedWidth(int(w))
panelWidth = QtCore.pyqtProperty(int, fget=get_panelWidth, fset=set_panelWidth)
def toggle(self):
self._set_collapsed(not self._target)
def _set_collapsed(self, collapsed: bool):
self._stop_anim()
self._target = collapsed
self.stack.setCurrentWidget(
self.collapse_page if collapsed else self.expand_page)
start_w = self.width()
# 展开时若记录的宽度超出当前窗口可承受范围 → 夹一下
self._end_w = self.W_COLLAPSE if collapsed else self._clamp_w(self.W_EXPAND)
end_w = self._end_w
self._anim = QtCore.QPropertyAnimation(self, b"panelWidth")
self._anim.setDuration(260)
self._anim.setStartValue(float(start_w))
self._anim.setEndValue(float(end_w))
self._anim.setEasingCurve(QtCore.QEasingCurve.Type.InOutCubic)
self._anim.finished.connect(self._on_anim_finished)
self._anim.start()
def _on_anim_finished(self):
self.setFixedWidth(self.W_COLLAPSE if self._target else self._end_w)
self.collapsed = self._target
self._stop_anim()
# ---------------- 宽度拖拽(右边界固定,左边界跟随鼠标)----------------
def _stop_anim(self):
if self._anim is not None:
try:
self._anim.stop()
self._anim.deleteLater()
except Exception:
pass
self._anim = None
def _clamp_w(self, w) -> int:
"""夹在 [W_MIN, min(W_MAX, 主窗口 50%)](最小宽度就在这里生效)"""
hi = self.W_MAX
par = self.parentWidget()
if par is not None and par.width() > 0:
hi = min(hi, max(self.W_MIN, int(par.width() * 0.5)))
return max(self.W_MIN, min(hi, int(w)))
def resizeEvent(self, e):
super().resizeEvent(e)
h = getattr(self, "_handle", None)
if h is not None:
h.setGeometry(0, 0, _ResizeHandle.W, self.height())
h.raise_()
self._repin_fold()
def _drag_begin(self, gx):
self._stop_anim()
# 动画中途按下 → 以当前实际宽度为准确定状态
self.collapsed = self.width() <= self.W_COLLAPSE + 8
self._target = self.collapsed
self.stack.setCurrentWidget(
self.collapse_page if self.collapsed else self.expand_page)
self._drag_right = self.mapToGlobal(QtCore.QPoint(self.width(), 0)).x()
def _drag_to_global_x(self, gx):
if self._drag_right is None:
return
w = self._clamp_w(self._drag_right - int(gx))
if self.collapsed:
if w <= self.W_COLLAPSE + 8:
return # 还在收起条内拖 → 不响应
self.collapsed = False # 从收起态直接拖开 → 立即进展开态
self._target = False
self.stack.setCurrentWidget(self.expand_page)
self._w = w
self.setFixedWidth(w)
def _commit_drag_width(self):
self._drag_right = None
if self.collapsed:
return
self.W_EXPAND = self._w
if save_panel_width(self._w):
print(f"[UI]: 任务面板宽度 -> {self._w}px(已记录,下次展开自动使用)")
# ---------------- 数据 ----------------
def set_session(self, session_id, db, active_stream=None):
"""切换会话 → 全量重建。已完成来自 DB 链;未落库的在跑任务来自 active_stream。"""
self._session_id = session_id
self._db = db
self._layers.clear()
self._order.clear()
self._running.clear()
self._done.clear()
# ① DB:本会话链上的历史 bash(标注是否还在上下文内)
try:
chain = db.get_message_chain(session_id) if db else []
except Exception:
chain = []
mark_idx = -1
for i, m in enumerate(chain):
if m.get("role") == "compaction":
mark_idx = i
for i, m in enumerate(chain):
msg_id = m.get("id") or ""
for e in _iter_bash_entries(m.get("timeline")):
cid = e.get("id") or f"dbmsg_{msg_id}_{i}"
if cid in self._layers:
continue
lay = BashLayer(cid, _cmd_of(e.get("args")))
lay.set_finished(bool(e.get("ok")), e.get("result") or "")
lay.set_in_context(i > mark_idx)
self._layers[cid] = lay
self._order.append(cid)
self._done.add(cid)
# ② 内存:正在进行的这一轮(包括已开始但未落库、以及刚结束未落库的)
for e in _iter_bash_entries((active_stream or {}).get("timeline")):
cid = e.get("id")
if not cid or cid in self._layers:
continue
lay = BashLayer(cid, _cmd_of(e.get("args")))
if e.get("ok") is None:
lay.set_running(0, 0)
if e.get("result"):
lay.append_live(str(e.get("result"))) # 尽力回放(上游上限 4000 字符)
self._running.add(cid)
else:
lay.set_finished(bool(e.get("ok")), e.get("result") or "")
self._done.add(cid)
self._layers[cid] = lay
self._order.append(cid)
self._refresh()
def _refresh(self):
run_ids = [c for c in self._order if c in self._running]
done_ids = [c for c in self._order if c in self._done]
self.sec_running.set_layers([self._layers[c] for c in run_ids])
shown = done_ids[-LAYER_LIMIT:]
self.sec_done.set_layers([self._layers[c] for c in shown], total=len(done_ids))
self.lbl_total.setText(f"{len(self._order)}" if self._order else "")
# ---------------- 事件 ----------------
def on_started(self, call_id, name, args):
if name != "bash" or not call_id or call_id in self._layers:
return
lay = BashLayer(call_id, _cmd_of(args))
lay.set_running(0, 0)
self._layers[call_id] = lay
self._order.append(call_id)
self._running.add(call_id)
self._refresh()
def on_output(self, call_id, text):
lay = self._layers.get(call_id)
if lay is not None and lay.ok is None:
lay.append_live(text)
def on_timed(self, call_id, elapsed, timeout):
lay = self._layers.get(call_id)
if lay is not None and lay.ok is None:
lay.set_running(elapsed, timeout)
def on_finished(self, call_id, name, ok, text):
lay = self._layers.get(call_id)
if lay is None:
if name != "bash" or not call_id:
return
lay = BashLayer(call_id, "")
self._layers[call_id] = lay
self._order.append(call_id)
self._running.discard(call_id)
self._done.add(call_id)
lay.set_finished(bool(ok), text or "")
lay.set_in_context(True)
self._refresh()
def clear_all(self):
self._layers.clear()
self._order.clear()
self._running.clear()
self._done.clear()
self._refresh()
# ---------------- 测试/调试辅助 ----------------
def layer_ids(self, which="all"):
if which == "running":
return [c for c in self._order if c in self._running]
if which == "done":
return [c for c in self._order if c in self._done]
return list(self._order)
+188
View File
@@ -0,0 +1,188 @@
# chat_bridge.py
import json
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal
class ChatBridge(QObject):
# 信号定义
regenerate_clicked = pyqtSignal(str) # 携带需要重新回答的助手消息ID
branch_switch_clicked = pyqtSignal(str, int) # 分支切换信号 (msg_id, direction: -1 为上一条, 1 为下一条)
delete_message_requested = pyqtSignal(str) # 删除消息信号 (msg_id)
attachment_clicked = pyqtSignal(str)
scroll_changed = pyqtSignal(float, float, float) # 页面 scrollY / scrollHeight / innerHeight
def __init__(self, page, channel, js_runner=None):
"""
:param page: QWebEnginePage 实例QtWebEngine 路径
:param channel: QWebChannel 实例QtWebEngine 路径
:param js_runner: 可选 JS 执行函数WebView2 路径script -> None
"""
super().__init__()
self.page = page
self._js_runner = js_runner
if channel is not None:
# 将自身注册到 channel 中,前端可通过 bridge 对象调用
channel.registerObject("bridge", self)
print("ChatBridge被初始化")
def run_js(self, script: str):
if self._js_runner is not None:
self._js_runner(script)
elif self.page is not None:
self.page.runJavaScript(script)
else:
print("[ChatBridge] run_js: 无可用执行通道")
# ---------- 消息构造 (Python -> JS) ----------
def create_message(self, msg_id: str, role: str, text: str = "", sender_name: str = "", branch_info: dict = None):
"""
在前端创建一条消息容器
:param branch_info: 格式如 {"current": 0, "total": 2}若为 None 则不显示分支切换器
"""
safe_text = json.dumps(text)
safe_name = json.dumps(sender_name)
safe_branch = json.dumps(branch_info) if branch_info else "null"
self.run_js(f"createMessage('{msg_id}', '{role}', {safe_text}, {safe_name}, {safe_branch});")
def create_long_message(self, msg_id: str, role: str, text: str, sender_name: str = ""):
safe_text = json.dumps(text)
safe_name = json.dumps(sender_name)
size_kb = round(len(text.encode('utf-8')) / 1024, 2)
self.run_js(f"createLongMessage('{msg_id}', '{role}', {safe_text}, {safe_name}, {size_kb});")
def create_user_message_with_attachments(self, msg_id: str, text: str, attachments: list):
safe_text = json.dumps(text)
safe_attachments = json.dumps(attachments)
self.run_js(f"createUserMessageWithAttachments('{msg_id}', {safe_text}, {safe_attachments});")
# ---------- 流式输出 (Python -> JS) ----------
def append_token(self, msg_id: str, token: str):
safe_token = json.dumps(token)
self.run_js(f"appendToken('{msg_id}', {safe_token});")
def append_reasoning(self, msg_id: str, token: str):
safe_token = json.dumps(token)
self.run_js(f"appendReasoning('{msg_id}', {safe_token});")
def finish_message(self, msg_id: str):
self.run_js(f"finishMessage('{msg_id}');")
# ---------- 工具执行事件 (Python -> JS) —— pi tool_execution_* ----------
def tool_execution_started(self, msg_id: str, call_id: str, name: str, args: str):
"""工具开始执行:前端在时间线当前位置插入 chip(带 call_id 对号入座)"""
safe_call = json.dumps(call_id)
safe_name = json.dumps(name)
safe_args = json.dumps(args)
self.run_js(f"toolExecutionStarted('{msg_id}', {safe_call}, {safe_name}, {safe_args});")
def tool_execution_updated(self, msg_id: str, call_id: str, text: str):
"""工具执行中的增量输出(bash stdout 等)"""
safe_call = json.dumps(call_id)
safe_text = json.dumps(text)
self.run_js(f"toolExecutionUpdated('{msg_id}', {safe_call}, {safe_text});")
def tool_execution_timed(self, msg_id: str, call_id: str,
elapsed: int, timeout: int):
"""🆕 bash 运行中每秒读秒:气泡摘要行刷新 N/Ts"""
safe_call = json.dumps(call_id)
self.run_js(f"toolExecutionTimed('{msg_id}', {safe_call}, "
f"{int(elapsed)}, {int(timeout)});")
def tool_execution_finished(self, msg_id: str, call_id: str, name: str, ok: bool, text: str):
"""工具执行结束:chip 按 call_id 标记成功/失败并显示结果摘要"""
safe_call = json.dumps(call_id)
safe_name = json.dumps(name)
safe_text = json.dumps(text)
self.run_js(f"toolExecutionFinished('{msg_id}', {safe_call}, {safe_name}, {str(ok).lower()}, {safe_text});")
# ---------- 时间线 (Python -> JS) ----------
def restore_streaming_timeline(self, msg_id: str, timeline_json: str):
"""切回进行中的会话:按时间线 JSON 恢复 思考/文本/工具 块(续流)"""
safe_json = json.dumps(timeline_json)
self.run_js(f"restoreStreamingTimeline('{msg_id}', {safe_json});")
def render_timeline_history(self, msg_id: str, timeline_json: str):
"""历史消息:按时间线 JSON 静态渲染 思考/文本/工具 块"""
safe_json = json.dumps(timeline_json)
self.run_js(f"renderTimelineHistory('{msg_id}', {safe_json});")
# ---------- 压缩可视化 (Python -> JS) ----------
def compaction_started(self, msg_id: str, path: str):
"""压缩开始 → 当前消息时间线内显示「上下文压缩」思考气泡(与深度思考同款)"""
safe_id = json.dumps(msg_id or "")
safe_path = json.dumps(path or "")
self.run_js(f"compactionStarted({safe_id}, {safe_path});")
def compaction_finished(self, msg_id: str, payload: dict):
"""压缩完成 → 同一气泡原地更新(前→后 token + 摘要全文)"""
safe_id = json.dumps(msg_id or "")
safe_payload = json.dumps(payload or {}, ensure_ascii=False)
self.run_js(f"compactionFinished({safe_id}, {safe_payload});")
# ---------- 系统提示 (Python -> JS) ----------
def show_note(self, text: str):
"""在聊天流中插入一条居中系统提示(如:已自动压缩上下文)"""
safe_text = json.dumps(text)
self.run_js(f"showSystemNote({safe_text});")
# ---------- 错误与清理 (Python -> JS) ----------
def show_error(self, msg_id: str, error_text: str):
safe_text = json.dumps(error_text)
self.run_js(f"showError('{msg_id}', {safe_text});")
def clear_chat(self):
self.run_js("clearChat();")
def delete_message(self, msg_id: str):
"""通知前端从 DOM 中移除特定消息"""
self.run_js(f"deleteMessage('{msg_id}');")
# ---------- 历史记录渲染 ----------
def render_history_message(self, msg_id: str, role: str, content: str, reasoning: str = "", branch_info: dict = None):
self.create_message(msg_id, role, content, branch_info=branch_info)
if reasoning:
safe_reasoning = json.dumps(reasoning)
self.run_js(f"insertThinkBlock('{msg_id}', {safe_reasoning});")
self.finish_message(msg_id)
def show_welcome(self):
self.run_js("showWelcome();")
# ---------- 会话加载界面 (Python -> JS) ----------
def show_loading(self):
"""显示统一加载界面(图标 + 从左到右扫描条)"""
self.run_js("showLoadingOverlay();")
def hide_loading(self):
"""隐藏加载界面(带渐变退场)"""
self.run_js("hideLoadingOverlay();")
# ---------- 交互 Slot (JS -> Python) ----------
@pyqtSlot(str)
def onRegenerateClicked(self, msg_id):
"""前端点击“重新生成”按钮时触发"""
print(f"Regenerate requested for: {msg_id}")
self.regenerate_clicked.emit(msg_id)
@pyqtSlot(str, int)
def onBranchSwitch(self, msg_id, direction):
"""前端点击分支切换箭头时触发"""
print(f"Branch switch requested for: {msg_id}, direction: {direction}")
self.branch_switch_clicked.emit(msg_id, direction)
@pyqtSlot(str)
def onDeleteMessageClicked(self, msg_id):
"""接收 JS 发来的确认删除指令(🌟 已去重,原先定义了两次)"""
print(f"Delete requested for: {msg_id}")
self.delete_message_requested.emit(msg_id)
@pyqtSlot(str)
def onAttachmentClicked(self, meta_json_str):
"""接收 JS 发来的附件点击事件"""
self.attachment_clicked.emit(meta_json_str)
@pyqtSlot(float, float, float)
def onScrollChanged(self, y, content_h, client_h):
"""接收前端上报的页面滚动信息,用于自定义滚动条位置/滑块长度镜像"""
self.scroll_changed.emit(y, content_h, client_h)
+66
View File
@@ -0,0 +1,66 @@
from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineScript
from PyQt6.QtCore import QUrl
from PyQt6.QtGui import QDesktopServices
class CustomWebPage(QWebEnginePage):
"""
自定义网页类集中约束内置 QtWebEngine 的不合规浏览器行为
1. 拦截所有外部链接跳转改用系统默认浏览器打开
2. 禁用 Ctrl/Meta + 滚轮 Ctrl + +/-/0 快捷键的页面缩放
浏览器式缩放对桌面聊天工具无意义且易误触
"""
def __init__(self, parent=None):
super().__init__(parent)
self._inject_zoom_lock()
def _inject_zoom_lock(self):
"""
🌟 缩放锁定通过网页脚本在文档创建阶段DocumentCreation注入监听
使用隔离世界ApplicationWorld注入不污染页面自身逻辑
原理Chromium Ctrl+滚轮缩放会先经过页面 wheel 事件的
preventDefault() 判定 页面阻止即取消缩放这是地图类应用
禁用缩放的官方做法Ctrl+=/-/0 快捷键同理用 keydown 拦截
"""
script = QWebEngineScript()
script.setName("haocode_zoom_lock")
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld)
script.setRunsOnSubFrames(True)
script.setSourceCode(r"""
(function () {
if (window.__haocodeZoomLocked) return; // 防重复注入
window.__haocodeZoomLocked = true;
// 1) Ctrl/Meta + 滚轮 阻止缩放
document.addEventListener('wheel', function (e) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
}
}, { passive: false }); // 必须非被动preventDefault 才有效
// 2) Ctrl + = / + / - / 0 阻止缩放快捷键
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && ['+', '-', '=', '0'].indexOf(e.key) >= 0) {
e.preventDefault();
}
}, false);
})();
""")
self.scripts().insert(script)
def acceptNavigationRequest(self, url: QUrl, nav_type, is_main_frame):
# 如果是本地文件(我们自己的 index.html),允许加载
if url.scheme() == "file":
return True
# 如果是外部链接(http/https),用系统浏览器打开
if url.scheme() in ["http", "https"]:
print(f"[System]: 在系统浏览器中打开 -> {url.toString()}")
QDesktopServices.openUrl(url)
return False # 阻止在应用内跳转
# 其他情况(如 javascript:void(0)),允许
return True
+214
View File
@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
"""独立调试器窗口 —— 与主窗口/项目树完全解耦(顶层窗口,parent=None
功能:
Tab1调试会话 实时 tail data/debug_session.log按来源着色:
[USER] / [AGENT]绿 / [APP] / [SYS]
Tab2应用日志 实时 tail diag.log + compaction_diag.log + stream_diag.log
底部输入框 用户输入观察到的情况回车 [USER]
按钮 暂停显示 / 清空会话日志 / 打开日志文件
控制: 代理写 data/debug_window.cmd (show/hide)主窗口 2s 轮询后调用本窗口
本文件不 import main_window可独立离屏测试
"""
import os
import re
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QTextCharFormat, QTextCursor, QColor
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit,
QLineEdit, QPushButton, QCheckBox, QLabel,
QTabWidget, QMessageBox)
from core.debug_log import DEBUG_LOG_PATH, DEBUG_CMD_PATH, debug_log
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_APP_LOGS = [
("DIAG", os.path.join(_ROOT, "diag.log")),
("COMPACT", os.path.join(_ROOT, "compaction_diag.log")),
("STREAM", os.path.join(_ROOT, "stream_diag.log")),
]
_TAG_COLORS = {"USER": "#2563eb", "AGENT": "#16a34a",
"APP": "#6b7280", "SYS": "#9333ea"}
_LINE_RE = re.compile(r"^\[([^\]]+)\]\s+\[([A-Z]+)\]\s?(.*)$")
class _TailReader:
"""单文件增量读取器(文件被截断时自动重置偏移)"""
def __init__(self, path: str):
self.path = path
self.offset = 0
def read_new(self) -> str:
try:
if not os.path.exists(self.path):
return ""
size = os.path.getsize(self.path)
if size < self.offset: # 被清空/轮转
self.offset = 0
if size == self.offset:
return ""
with open(self.path, "r", encoding="utf-8", errors="replace") as f:
f.seek(self.offset)
data = f.read()
self.offset = size
return data
except Exception:
return ""
class DebugWindow(QWidget):
def __init__(self):
super().__init__(None) # 顶层独立窗口
self.setWindowTitle("Haocode 调试器")
self.resize(780, 540)
self.setWindowFlags(Qt.WindowType.Window)
# 默认停靠主屏右上角,避免被主窗口挡住
try:
from PyQt6.QtGui import QGuiApplication
_geo = QGuiApplication.primaryScreen().availableGeometry()
self.move(_geo.right() - self.width() - 24, _geo.top() + 24)
except Exception:
pass
self._paused = False
self._announced = False
root = QVBoxLayout(self)
root.setContentsMargins(8, 8, 8, 8)
# ---- Tab 容器 ----
self._tabs = QTabWidget()
self._view_session = QPlainTextEdit()
self._view_session.setReadOnly(True)
self._view_session.setMaximumBlockCount(3000)
self._view_session.setLineWrapMode(
QPlainTextEdit.LineWrapMode.NoWrap)
self._view_app = QPlainTextEdit()
self._view_app.setReadOnly(True)
self._view_app.setMaximumBlockCount(3000)
self._view_app.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self._tabs.addTab(self._view_session, "调试会话")
self._tabs.addTab(self._view_app, "应用日志")
root.addWidget(self._tabs, 1)
# ---- 状态行 ----
status = QLabel(f"会话日志: {DEBUG_LOG_PATH}\n"
f"控制文件: {DEBUG_CMD_PATH} (show/hide)")
status.setStyleSheet("color:#888; font-size:11px;")
status.setWordWrap(True)
root.addWidget(status)
# ---- 按钮行 ----
btn_row = QHBoxLayout()
self._chk_pause = QCheckBox("暂停显示(记录继续)")
self._chk_pause.toggled.connect(self._on_pause_toggled)
self._chk_top = QCheckBox("置顶")
self._chk_top.toggled.connect(self._on_top_toggled)
btn = QPushButton("清空会话日志")
btn.clicked.connect(self._on_clear)
btn2 = QPushButton("打开日志文件")
btn2.clicked.connect(self._on_open_file)
btn_row.addWidget(self._chk_pause)
btn_row.addWidget(self._chk_top)
btn_row.addStretch(1)
btn_row.addWidget(btn)
btn_row.addWidget(btn2)
root.addLayout(btn_row)
# ---- 用户输入行 ----
in_row = QHBoxLayout()
hint = QLabel("观察到的情况(回车记录为 [USER]):")
hint.setStyleSheet("color:#555; font-size:12px;")
self._input = QLineEdit()
self._input.setPlaceholderText("例如: 上下文标签显示 40.5k,刚发送了「接着输出」")
self._input.returnPressed.connect(self._on_submit)
in_row.addWidget(hint)
in_row.addWidget(self._input, 1)
root.addLayout(in_row)
# ---- 文件增量读取器 + 轮询 ----
self._reader_session = _TailReader(DEBUG_LOG_PATH)
self._readers_app = {tag: _TailReader(p) for tag, p in _APP_LOGS}
self._timer = QTimer(self)
self._timer.setInterval(500)
self._timer.timeout.connect(self._tick)
self._timer.start()
# ==================== 轮询 ====================
def _tick(self):
try:
if not self._announced:
self._announced = True
debug_log("调试窗口开启", "SYS")
if not self._paused:
data = self._reader_session.read_new()
if data:
self._append_tagged(self._view_session, data)
for tag, _p in _APP_LOGS:
d = self._readers_app[tag].read_new()
if d:
self._view_app.appendPlainText(
f"── [{tag}] {os.path.basename(_p)} ──")
self._view_app.appendPlainText(d.rstrip("\n"))
except Exception:
pass
def _append_tagged(self, view: QPlainTextEdit, data: str):
cur = view.textCursor()
cur.movePosition(QTextCursor.MoveOperation.End)
for line in data.splitlines():
if not line:
continue
m = _LINE_RE.match(line)
color = _TAG_COLORS.get(m.group(2)) if m else None
fmt = QTextCharFormat()
if color:
fmt.setForeground(QColor(color))
cur.insertText(line + "\n", fmt)
view.setTextCursor(cur)
view.ensureCursorVisible()
# ==================== 交互 ====================
def _on_submit(self):
text = self._input.text().strip()
if not text:
return
debug_log(text, "USER")
self._input.clear()
def _on_pause_toggled(self, checked: bool):
self._paused = checked
def _on_top_toggled(self, checked: bool):
f = self.windowFlags()
if checked:
f |= Qt.WindowType.WindowStaysOnTopHint
else:
f &= ~Qt.WindowType.WindowStaysOnTopHint
self.setWindowFlags(f)
self.show() # setWindowFlags 会隐藏窗口,需重新 show
def _on_clear(self):
if QMessageBox.question(
self, "清空会话日志",
"将清空 debug_session.logUSER/AGENT/APP 记录全部丢失),确定?") \
!= QMessageBox.StandardButton.Yes:
return
try:
open(DEBUG_LOG_PATH, "w", encoding="utf-8").close()
self._reader_session.offset = 0
self._view_session.clear()
debug_log("会话日志已清空", "SYS")
except Exception:
pass
def _on_open_file(self):
try:
import subprocess
if os.name == "nt":
os.startfile(DEBUG_LOG_PATH) # noqa
else:
subprocess.Popen(["xdg-open", DEBUG_LOG_PATH])
except Exception:
pass
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
"""
系统级工具集合 (system_tools)
- screen_capture: 屏幕截图覆盖层
- global_hotkey: 全局热键监听线程
- file_reader: 文本/代码文件读取编码探测 + 二进制探测
"""
+71
View File
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""
文本/代码文件读取工具 (file_reader)
编码探测 + 二进制探测 + 大小守卫供附件系统拖拽/粘贴/上传文本与代码文件复用
用法read_text_file(path) -> (content, encoding, size_kb, lines)失败抛 ValueError
说明纯函数模块不依赖 Qt便于单元测试
"""
import os
# 明确拒绝的二进制/文档格式(Word/Excel/PPT/PDF/压缩包/可执行/媒体等)
BINARY_EXTS = frozenset({
# Office 文档(明确排除 Word/Excel
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.odt', '.ods', '.odp', '.rtf',
# 电子书(.pdf 已交由 tools/builtin_tools/pdf_reader.py 专门解析,不在此拦截)
'.epub', '.mobi',
# 压缩包 / 磁盘镜像
'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.iso', '.dmg',
# 可执行 / 编译产物
'.exe', '.dll', '.so', '.dylib', '.msi', '.bin', '.apk', '.jar',
'.class', '.pyc', '.pyd', '.o', '.a',
# 音视频
'.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg',
'.mp4', '.avi', '.mov', '.mkv', '.webm', '.wmv',
# 字体
'.ttf', '.otf', '.woff', '.woff2', '.eot',
# 数据库
'.db', '.sqlite', '.sqlite3', '.mdb',
# 设计稿
'.psd', '.ai', '.sketch', '.fig',
})
# 单文件大小上限(字节):超过则拒绝,避免内存与上下文爆炸
MAX_ATTACH_FILE_BYTES = 2 * 1024 * 1024 # 2 MB
# 编码降级链:UTF-8(含BOM) → GB18030(⊇GBK/GB2312) → latin-1(永不失败)
_ENCODINGS = ('utf-8-sig', 'gb18030', 'latin-1')
def read_text_file(path, max_bytes=MAX_ATTACH_FILE_BYTES):
"""读取一个文本/代码文件。
返回 (content, encoding, size_kb, lines)
文件过大或判定为二进制时抛出 ValueError附带可读原因
"""
size = os.path.getsize(path)
if size > max_bytes:
raise ValueError(
f"文件过大({size / 1024 / 1024:.1f} MB > {max_bytes / 1024 / 1024:.0f} MB"
)
with open(path, 'rb') as f:
raw = f.read()
# 二进制探测:前 8KB 含 NUL 字节即判定为二进制(git 同款启发式)
if b'\x00' in raw[:8192]:
raise ValueError("二进制文件,无法作为文本读取")
text, used_enc = None, 'utf-8'
for enc in _ENCODINGS:
try:
text = raw.decode(enc)
used_enc = enc
break
except UnicodeDecodeError:
continue
if text is None: # 理论上 latin-1 兜底永不失败
raise ValueError("无法识别文件编码")
lines = text.count('\n') + 1
return text, used_enc, round(size / 1024, 2), lines
+105
View File
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
"""
全局热键监听器 (Windows)
独立线程 + Win32 RegisterHotKey 消息循环实现主窗口失焦也能触发的系统级全局快捷键
用法
hotkey = GlobalHotkeyThread() # 默认 Alt+S
hotkey.triggered.connect(on_triggered) # 跨线程信号,槽函数在主线程执行
hotkey.start() # 启动线程
hotkey.stop() # 注销热键并退出线程
"""
import ctypes
import sys
import threading
from ctypes import wintypes
from PyQt6 import QtCore
# Win32 常量
WM_HOTKEY = 0x0312
WM_QUIT = 0x0012
MOD_ALT = 0x0001
MOD_CONTROL = 0x0002
MOD_SHIFT = 0x0004
MOD_NOREPEAT = 0x4000 # 按住不重复触发 (Windows 7+)
VK_S = 0x53
_is_windows = sys.platform == "win32"
if _is_windows:
_user32 = ctypes.windll.user32
_kernel32 = ctypes.windll.kernel32
# 显式声明函数签名,避免 ctypes 默认 int 截断指针/句柄
_user32.RegisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int, wintypes.UINT, wintypes.UINT]
_user32.RegisterHotKey.restype = wintypes.BOOL
_user32.UnregisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int]
_user32.UnregisterHotKey.restype = wintypes.BOOL
_user32.GetMessageW.argtypes = [
ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT
]
_user32.GetMessageW.restype = wintypes.BOOL # >0 正常 / 0 WM_QUIT / -1 出错
_user32.PostThreadMessageW.argtypes = [
wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM
]
_user32.PostThreadMessageW.restype = wintypes.BOOL
_kernel32.GetCurrentThreadId.restype = wintypes.DWORD
class GlobalHotkeyThread(QtCore.QThread):
"""在独立线程注册系统级全局热键并运行 Win32 消息循环。
收到 WM_HOTKEY 后通过 Qt 信号 triggered 通知主线程
信号槽机制保证槽函数在主线程执行操作 Qt 控件安全
"""
triggered = QtCore.pyqtSignal()
def __init__(self, hotkey_id=9001, mod=MOD_ALT, vk=VK_S, parent=None):
super().__init__(parent)
self._hotkey_id = hotkey_id
self._mod = mod
self._vk = vk
self._thread_id = 0
self._ready = threading.Event() # run() 记录线程 ID 后置位
self._registered = False
def run(self):
"""线程入口:注册热键 -> 消息循环 -> 退出时注销"""
if not _is_windows:
self._ready.set()
return
self._thread_id = _kernel32.GetCurrentThreadId()
ok = _user32.RegisterHotKey(
None, self._hotkey_id, self._mod | MOD_NOREPEAT, self._vk
)
self._registered = bool(ok)
self._ready.set()
if not ok:
print(
f"[GlobalHotkey] 注册热键失败 (id={self._hotkey_id}, mod={self._mod:#x}, "
f"vk={self._vk:#x}),可能已被其他程序占用"
)
return
# Win32 消息循环:hwnd=None 取本线程所有消息
msg = wintypes.MSG()
while _user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
if msg.message == WM_HOTKEY and msg.wParam == self._hotkey_id:
self.triggered.emit()
# 收到 WM_QUIT 退出循环 -> 注销热键
_user32.UnregisterHotKey(None, self._hotkey_id)
self._registered = False
def stop(self):
"""请求线程退出:向线程消息队列投递 WM_QUIT,然后等待结束"""
if not _is_windows:
return
# 等 run() 至少记录好线程 ID(注册成功或失败都行)
self._ready.wait(timeout=2.0)
if self._thread_id:
_user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0)
self.wait(2000)
+193
View File
@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
"""
屏幕截图覆盖层 (ScreenCaptureOverlay)
全屏半透明遮罩 + 鼠标拖拽选区 + 确认/取消工具条 + 截图完成发射 QImage 信号
用法调用 start() 启动截图监听 screenshot_captured 信号获取结果
"""
from PyQt6 import QtCore, QtGui, QtWidgets
import os
class ScreenCaptureOverlay(QtWidgets.QWidget):
"""全屏截图覆盖层:半透明遮罩 + 鼠标拖拽选区 + 确认/取消按钮"""
screenshot_captured = QtCore.pyqtSignal(QtGui.QImage)
# 按钮尺寸
BTN_W = 36
BTN_H = 30
BTN_GAP = 4
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(
QtCore.Qt.WindowType.FramelessWindowHint
| QtCore.Qt.WindowType.WindowStaysOnTopHint
| QtCore.Qt.WindowType.Tool
)
self.setCursor(QtCore.Qt.CursorShape.CrossCursor)
self._full_pixmap = None
self._start_pos = QtCore.QPoint()
self._end_pos = QtCore.QPoint()
self._is_drawing = False
self._has_selection = False
self._current_rect = QtCore.QRect()
# SVG 图标路径(项目根目录下 svg/ 文件夹)
root_dir = os.path.abspath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..")
)
# 确认 / 取消按钮(使用 SVG 图标,灰白简约风背景)
self._btn_confirm = QtWidgets.QPushButton(self)
self._btn_cancel = QtWidgets.QPushButton(self)
self._btn_confirm.setIcon(QtGui.QIcon(os.path.join(root_dir, "svg", "check.svg")))
self._btn_cancel.setIcon(QtGui.QIcon(os.path.join(root_dir, "svg", "cross.svg")))
btn_style = (
"QPushButton { background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 4px; }"
"QPushButton:hover { background-color: #eee; border-color: #ccc; }"
)
for btn in (self._btn_confirm, self._btn_cancel):
btn.setFixedSize(self.BTN_W, self.BTN_H)
btn.setIconSize(QtCore.QSize(20, 20))
btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
btn.setStyleSheet(btn_style)
btn.hide()
self._btn_confirm.clicked.connect(self._on_confirm)
self._btn_cancel.clicked.connect(self._on_cancel)
def start(self):
"""开始截图:抓取屏幕全图并显示覆盖层"""
screen = QtWidgets.QApplication.primaryScreen()
if not screen:
return
self._full_pixmap = screen.grabWindow(0)
self.setGeometry(screen.geometry())
self.show()
self.activateWindow()
self.raise_()
def paintEvent(self, event):
if not self._full_pixmap:
return
painter = QtGui.QPainter(self)
# 1. 绘制屏幕截图作为背景
painter.drawPixmap(0, 0, self._full_pixmap)
# 2. 半透明遮罩
painter.fillRect(self.rect(), QtGui.QColor(0, 0, 0, 100))
# 绘制中 或 已有选区 时,绘制选区高亮
if self._is_drawing or self._has_selection:
if self._is_drawing:
rect = QtCore.QRect(self._start_pos, self._end_pos).normalized()
else:
rect = self._current_rect
if rect.width() > 0 and rect.height() > 0:
# 3. 选区内重绘原图(去掉遮罩,形成高亮效果)
painter.drawPixmap(rect, self._full_pixmap, rect)
# 4. 蓝色边框
pen = QtGui.QPen(QtGui.QColor(0, 120, 215), 2)
painter.setPen(pen)
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
painter.drawRect(rect)
# 5. 尺寸标注
size_text = f"{rect.width()} x {rect.height()}"
font = painter.font()
font.setPointSize(9)
painter.setFont(font)
fm = painter.fontMetrics()
text_w = fm.horizontalAdvance(size_text)
text_h = fm.height()
text_x = rect.x()
text_y = rect.y() - text_h - 2
if text_y < 0:
text_y = rect.bottom() + 2
painter.fillRect(text_x, text_y, text_w + 10, text_h, QtGui.QColor(0, 120, 215))
painter.setPen(QtGui.QColor(255, 255, 255))
painter.drawText(text_x + 5, text_y + fm.ascent(), size_text)
painter.end()
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.MouseButton.LeftButton:
# 开始新选区,隐藏按钮
self._hide_buttons()
self._has_selection = False
self._start_pos = event.position().toPoint()
self._end_pos = self._start_pos
self._is_drawing = True
self.update()
elif event.button() == QtCore.Qt.MouseButton.RightButton:
self.close()
def mouseMoveEvent(self, event):
if self._is_drawing:
self._end_pos = event.position().toPoint()
self.update()
def mouseReleaseEvent(self, event):
if event.button() == QtCore.Qt.MouseButton.LeftButton and self._is_drawing:
self._is_drawing = False
rect = QtCore.QRect(self._start_pos, self._end_pos).normalized()
if rect.width() > 5 and rect.height() > 5:
# 保留选区,显示确认/取消按钮
self._current_rect = rect
self._has_selection = True
self._position_buttons(rect)
self._btn_confirm.show()
self._btn_cancel.show()
self.update()
else:
self.close()
def keyPressEvent(self, event):
if event.key() == QtCore.Qt.Key.Key_Escape:
self.close()
elif event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter):
if self._has_selection:
self._on_confirm()
def _position_buttons(self, rect):
"""将确认/取消按钮定位到选区右下角"""
total_w = self.BTN_W * 2 + self.BTN_GAP
# 默认放在选区右下角外侧
x = rect.right() - total_w
y = rect.bottom() + 4
# 边界检测:超出屏幕底部时翻到选区内侧
if y + self.BTN_H > self.height():
y = rect.bottom() - self.BTN_H - 4
if x < 0:
x = 0
self._btn_confirm.move(x, y)
self._btn_cancel.move(x + self.BTN_W + self.BTN_GAP, y)
def _hide_buttons(self):
self._btn_confirm.hide()
self._btn_cancel.hide()
def _on_confirm(self):
"""确认截图:裁剪并发射信号"""
if self._full_pixmap and self._current_rect.width() > 5 and self._current_rect.height() > 5:
dpr = self._full_pixmap.devicePixelRatio()
phys_rect = QtCore.QRect(
int(self._current_rect.x() * dpr),
int(self._current_rect.y() * dpr),
int(self._current_rect.width() * dpr),
int(self._current_rect.height() * dpr),
)
captured = self._full_pixmap.toImage().copy(phys_rect)
self.screenshot_captured.emit(captured)
self.close()
def _on_cancel(self):
"""取消截图:直接关闭"""
self.close()
def closeEvent(self, event):
self._is_drawing = False
self._has_selection = False
self._hide_buttons()
self._full_pixmap = None
super().closeEvent(event)
+217
View File
@@ -0,0 +1,217 @@
# -*- coding: utf-8 -*-
"""
WebView2View 外观兼容 QWebEngineView QWidget仅实现 main_window 用到的 API
setUrl / loadFinished / page().runJavaScript / page().setBackgroundColor / grab
内部由 core.webview2.Wv2Session 驱动
- 30ms timer + resizeEvent 双路 SetBoundsAndZoomFactorslot 矩形 父窗客户区物理像素
- WebMessageReceived ChatBridge JSPython slot
- run_js ExecuteScriptAsync QWebChannel 路径生成的 JS 文本同构
"""
import json
import ctypes
import ctypes.wintypes as wintypes
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QUrl
# JS → Python 消息名 → ChatBridge slot 名(与 QWebChannel 注册的对象方法一一对应)
_BRIDGE_METHODS = (
"onRegenerateClicked",
"onBranchSwitch",
"onDeleteMessageClicked",
"onAttachmentClicked",
"onScrollChanged",
)
class _PageShim:
"""QWebEnginePage 的最小替身"""
def __init__(self, view):
self._view = view
def runJavaScript(self, script, callback=None):
if callback is None:
self._view.run_js(script)
else:
self._view.execute_js_async(script, callback)
def setBackgroundColor(self, color):
pass # WebView2 底色由页面 CSS 决定(页面本身就是白底)
class WebView2View(QtWidgets.QWidget):
loadFinished = pyqtSignal(bool)
def __init__(self, session, parent=None):
super().__init__(parent)
self._session = session
self._page = _PageShim(self)
self._bridge = None
self._session.on_load_finished = self._on_page_loaded
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
# 子窗口发现(异步创建)
self._child_timer = QTimer(self)
self._child_timer.setInterval(100)
self._child_timer.timeout.connect(self._try_find_child)
self._child_timer.start()
# bounds 同步兜底(resizeEvent 主路 + 30ms 兜底,与 P1 实测一致)
self._sync_timer = QTimer(self)
self._sync_timer.setInterval(30)
self._sync_timer.timeout.connect(self.sync_bounds)
self._sync_timer.start()
# 几何去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞)
self._last_sent_bounds = None
# ---------- 页面加载完成 ----------
def _on_page_loaded(self):
# 立即启动 JS 管线(子窗口全程可见,无节流/竞态)。
# 首帧前的"透底色"问题由 Qt 侧白色 paintEvent 解决(透明子窗表面露出白底)。
self.loadFinished.emit(True)
def paintEvent(self, ev):
# 子窗口隐藏期间(启动首帧前)槽位必须自绘白底,
# 否则 WA_OpaquePaintEvent + 无 paintEvent = 未绘制表面 = 纯黑
p = QtGui.QPainter(self)
p.fillRect(self.rect(), QtGui.QColor("#ffffff"))
p.end()
# ---------- QWebEngineView 兼容 API ----------
def page(self):
return self._page
def setUrl(self, url):
# 接受 QUrl 或 str
s = url.toString() if isinstance(url, QUrl) else str(url)
self._session.navigate(s)
def grab(self):
"""子窗口区域截图(调试工具用)"""
h = self._session.child_hwnd
if not h:
return QtGui.QPixmap(self.size())
r = wintypes.RECT()
if not ctypes.windll.user32.GetWindowRect(wintypes.HWND(h), ctypes.byref(r)):
return QtGui.QPixmap(self.size())
w = max(1, r.right - r.left)
hgt = max(1, r.bottom - r.top)
screen_dc = ctypes.windll.user32.GetDC(0)
mem_dc = ctypes.windll.gdi32.CreateCompatibleDC(screen_dc)
bmp = ctypes.windll.gdi32.CreateCompatibleBitmap(screen_dc, w, hgt)
ctypes.windll.gdi32.SelectObject(mem_dc, bmp)
ctypes.windll.gdi32.BitBlt(mem_dc, 0, 0, w, hgt, screen_dc, r.left, r.top, 0x00CC0020)
# BITMAPINFO
class _BMI(ctypes.Structure):
_fields_ = [("biSize", wintypes.DWORD), ("biWidth", ctypes.c_long),
("biHeight", ctypes.c_long), ("biPlanes", wintypes.WORD),
("biBitCount", wintypes.WORD), ("biCompression", wintypes.DWORD),
("biSizeImage", wintypes.DWORD), ("biXPelsPerMeter", ctypes.c_long),
("biYPelsPerMeter", ctypes.c_long), ("biClrUsed", wintypes.DWORD),
("biClrImportant", wintypes.DWORD)]
bmi = _BMI()
bmi.biSize = ctypes.sizeof(_BMI)
bmi.biWidth = w
bmi.biHeight = -hgt # 顶向下
bmi.biPlanes = 1
bmi.biBitCount = 32
bmi.biCompression = 0
buf = ctypes.create_string_buffer(w * hgt * 4)
ctypes.windll.gdi32.GetDIBits(mem_dc, bmp, 0, hgt, buf, ctypes.byref(bmi), 0)
ctypes.windll.gdi32.DeleteObject(bmp)
ctypes.windll.gdi32.DeleteDC(mem_dc)
ctypes.windll.user32.ReleaseDC(0, screen_dc)
img = QtGui.QImage(buf, w, hgt, w * 4, QtGui.QImage.Format.Format_RGBA8888).copy()
return QtGui.QPixmap.fromImage(img)
# ---------- 桥接 ----------
def attach_bridge(self, bridge):
self._bridge = bridge
self._session.on_message = self._dispatch_message
def _dispatch_message(self, data):
if not isinstance(data, dict) or not self._bridge:
return
name = data.get("m")
args = data.get("a", [])
if name in _BRIDGE_METHODS:
fn = getattr(self._bridge, name, None)
if fn:
try:
fn(*args)
except Exception as ex:
print(f"[WV2] bridge {name} error:", ex)
# ---------- JS → Python 用的执行通道 ----------
def run_js(self, script):
self._session.execute_js(script)
def execute_js_async(self, script, cb):
self._session.execute_js_async(script, cb)
# ---------- 几何同步 ----------
def _dpr(self):
try:
d = self.dpr()
return d if d > 0 else 1.0
except Exception:
return 1.0
def sync_bounds(self):
if not self.isVisible() or not self._session.child_hwnd:
return
top = self.window()
p = self.mapTo(top, QtCore.QPoint(0, 0))
d = self._dpr()
L, T = p.x() * d, p.y() * d
W, H = max(1, self.width() * d), max(1, self.height() * d)
# 去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞 → 整窗黑屏)
# 注:旧版 resize hold(放大时钳制旧尺寸→露白边)已移除 ——
# 拖动黑边的真凶是假异步 JS 泵(已修真异步),高频 SetBounds 实测 Chromium 完全跟得上;
# 用户实测:立即发送 = 缩小/放大都实时渲染、无阻塞。
if self._last_sent_bounds == (L, T, W, H):
return
self._session.set_bounds(L, T, W, H)
self._last_sent_bounds = (L, T, W, H)
def resizeEvent(self, ev):
super().resizeEvent(ev)
self.sync_bounds()
def showEvent(self, ev):
super().showEvent(ev)
# 确保渲染层点亮(SDK 初始状态下控制器可能不可见)
self._session.set_visible(True)
self.sync_bounds()
def _force_repaint(self):
"""WebView2 子窗口创建会打断首帧合成 → 强制整窗重绘(治启动时控件渲染丢失)"""
top = self.window()
if top is None:
return
top.update()
QtWidgets.QApplication.processEvents()
def _try_find_child(self):
if self._session.child_hwnd:
self._child_timer.stop()
self._session.set_visible(True)
self.sync_bounds()
self._force_repaint()
QtCore.QTimer.singleShot(300, self._force_repaint)
return
if self._session.find_child_once():
self._child_timer.stop()
self._session.set_visible(True)
self.sync_bounds()
self._force_repaint()
QtCore.QTimer.singleShot(300, self._force_repaint)
# ---------- 生命周期 ----------
def closeEvent(self, ev):
try:
self._child_timer.stop()
self._sync_timer.stop()
self._session.close()
except Exception:
pass
super().closeEvent(ev)
+2111
View File
File diff suppressed because it is too large Load Diff
+3
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
+1213
View File
File diff suppressed because one or more lines are too long
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Markdown 库 -->
<script src="marked.min.js"></script>
<!-- 代码高亮 -->
<link rel="stylesheet" href="highlight/atom-one-dark.min.css">
<script src="highlight/highlight.min.js"></script>
<!-- 🌟 HTML 消毒(防 XSS / 防 HTML 穿透的纵深防御) -->
<script src="dompurify.min.js"></script>
<!-- 🌟 KaTeX 公式渲染(本地离线资产;须在 app.js 之前) -->
<link rel="stylesheet" href="katex/katex.min.css">
<script src="katex/katex.min.js"></script>
<!-- 自定义样式 -->
<link rel="stylesheet" href="style.css">
<!-- 🌟 Qt WebChannel 支持(用于前端与 Python 通信) -->
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
</head>
<body>
<!--🌟 会话加载界面(进入新会话时的统一加载:居中图标 + 从左到右扫描条) -->
<div class="session-loading" id="session-loading" style="display:none;">
<div class="session-loading-box">
<img class="session-loading-icon" src="../../svg/main.svg" alt="haocode">
<div class="session-loading-bar">
<div class="session-loading-bar-fill"></div>
</div>
</div>
</div>
<!--🌟 欢迎界面 -->
<div class="welcome-screen">
<div class="welcome-content">
<img class="welcome-icon" src="../../svg/main.svg" alt="haocode">
<h1 class="brand-name">haocode</h1>
<p class="brand-tagline">探索未至之境</p>
</div>
</div>
<!-- 聊天容器 -->
<div id="chat-container"></div>
<div id="scroll-anchor" style="height: 1px; margin-bottom: 150px;"></div>
<!-- 🌟 桥接初始化脚本(必须在 app.js 之前执行)
双协议:WebView2window.chrome.webview.postMessage/ QtWebEngineQWebChannel -->
<script>
document.addEventListener("DOMContentLoaded", function () {
if (window.chrome && window.chrome.webview) {
// ---- WebView2 路径:JSON 消息协议,方法名与 QWebChannel slot 一一对应 ----
const post = (m, a) => {
try { window.chrome.webview.postMessage({ m: m, a: a }); } catch (e) { console.warn("[JS] postMessage 失败:", e); }
};
window.bridge = {
onRegenerateClicked: (x) => post("onRegenerateClicked", [x]),
onBranchSwitch: (x, d) => post("onBranchSwitch", [x, d]),
onDeleteMessageClicked: (x) => post("onDeleteMessageClicked", [x]),
onAttachmentClicked: (x) => post("onAttachmentClicked", [x]),
onScrollChanged: (y, h, c) => post("onScrollChanged", [y, h, c])
};
console.log("[JS] WebView2 桥接就绪,window.bridge 可用");
} else {
// ---- QtWebEngine 路径:原有 QWebChannel ----
new QWebChannel(qt.webChannelTransport, function (channel) {
window.bridge = channel.objects.bridge;
console.log("[JS] QWebChannel 已连接,window.bridge 就绪");
});
}
});
</script>
<!-- 主逻辑脚本 -->
<script src="app.js"></script>
</body>
</html>
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More