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.
@@ -0,0 +1,10 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
# Runtime data: config with API keys, database, attachments, locks. Never commit.
|
||||
data/
|
||||
|
||||
# Diagnostics / logs
|
||||
*.log
|
||||
@@ -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.py,QThread 胶水层);
|
||||
多轮工具循环在一次发送内部闭环,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/assistant;toolResult 不可切);
|
||||
切点非 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 节流直接写 DOM(token 由 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 0(rAF+全部页面定时器 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 主因结论)
|
||||
人肉 debug(token 级日志 + 屏幕截图 + 逐层盒模型探针 + 页面内对照/克隆实验)锁定:
|
||||
|
||||
**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 后正文段体检日志
|
||||
|
||||
@@ -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.11(js + 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_stream:text/reasoning/toolcall 增量 + usage + stop_reason)
|
||||
├─ 出错 → recovery._handle_post_agent_run:溢出→压缩重试 / 可重试→退避重试 / 否则停
|
||||
└─ 有 tool_calls → execute_tool_calls(tools.py 的四个工具,并行/串行按批次规则)
|
||||
|
||||
⑤ 事件回流 UI(llm_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' 标记行
|
||||
|
||||
⑦ 下一次提问回到 ②,此时模型能看到上一轮的正文、工具调用与结果(失败轮次亦然)
|
||||
```
|
||||
@@ -0,0 +1,175 @@
|
||||
# haocode Agent 系统提示词
|
||||
|
||||
## 0. 身份
|
||||
|
||||
你是 **haocode**(worker 模式)的本地智能体,运行在用户本机的 PyQt6 桌面应用内,
|
||||
通过内嵌浏览器界面与用户对话。你具备代码理解与修改能力,并能调用工具直接操作
|
||||
本机文件与 shell。你的目标是把用户交代的任务**真正做完**(改文件、跑命令、验证结果),
|
||||
而不是只给出建议或描述该怎么做。
|
||||
|
||||
- 回答语言:**简体中文**;代码、命令、路径、标识符保持原文。
|
||||
- 你在一个多轮循环里工作:可以连续调用工具、观察结果、再决定下一步,
|
||||
直到任务完成或确实需要用户决策为止。
|
||||
|
||||
## 1. 运行环境
|
||||
|
||||
- 操作系统:Windows。文件路径形如 `C:\Users\14890\Desktop\haocode`。
|
||||
- 当前工作目录:**haocode 项目根目录**。所有相对路径都相对它解析;
|
||||
每条 bash 命令都以它作为工作目录启动。
|
||||
- Python:conda 环境 `haocode`(Python 3.10,PyQt6、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)」类提示,说明上一轮输出触达长度上限,
|
||||
请缩短回答或把任务拆成更小的步骤。
|
||||
- **工具调用与结果会留在历史里**:不要重复已经做过的探查,也不要凭「上一轮我大概做过什么」
|
||||
下结论而不看返回结果。
|
||||
- 用户可能随时中止(`操作已中止`):如实说明当前进度,不要继续动作。
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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 实例可反复 prompt(state.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-time(pi 默认)
|
||||
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
|
||||
@@ -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.py(usage 锚定,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_summary(maxTokens = 0.8×reserve)
|
||||
generateTurnPrefixSummary -> generate_turn_prefix_summary(0.5×reserve)
|
||||
compact -> compact_context(断轮双摘要 + 拼接格式 1:1)
|
||||
packages/agent/src/harness/compaction/utils.ts
|
||||
serializeConversation -> serialize_conversation(1: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 + retry;haocode 用 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|written;readOnly = 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
|
||||
@@ -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 estimateContextTokens(usage 锚定):
|
||||
- 找到最后一条带有效 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 clampMaxTokensToContext(1:1 公式):
|
||||
|
||||
input_estimate = estimateContextTokens(context) # 🆕 P2: 含 system+tools(pi 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 shouldCompact(harness 原版公式):
|
||||
contextTokens > contextWindow - reserveTokens
|
||||
reserveTokens 默认 16384(DEFAULT_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
|
||||
@@ -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 入口发)
|
||||
注入 pending(message_start/end → 入 context + newMessages)
|
||||
assistant = streamAssistantResponse(...)
|
||||
if stopReason in (error, aborted): turn_end + agent_end + return
|
||||
toolCalls = assistant 的 toolCall 块
|
||||
if toolCalls:
|
||||
length → failToolCallsFromTruncatedMessage(不执行!)
|
||||
否则 → executeToolCalls(sequential/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
|
||||
# 采用权威 final:usage / 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]:
|
||||
"""对照 executeToolCallsParallel:start 事件串行发、准备串行、执行并发、
|
||||
结果消息按原始顺序产出"""
|
||||
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"
|
||||
@@ -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, 8s;maxRetries 默认 3)
|
||||
packages/ai/src/utils/overflow.ts
|
||||
isContextOverflow (行 134):三种溢出识别
|
||||
isRecoverableLength:length 且 output < 原始输出上限
|
||||
|
||||
🌟 决策表(与 pi 完全一致,基于最后一条 assistant 消息):
|
||||
(1) stop_reason=="error":
|
||||
a. 是上下文溢出 → 删除坏消息 + 压缩 + continue(只试一次)
|
||||
b. 命中可重试模式(429/5xx/超时/断连…且非配额耗尽)
|
||||
且未超 maxRetries → 删除坏消息 + 退避(2s×2^(n-1)) + continue
|
||||
c. 其他 → 结束
|
||||
(2) isRecoverableLength(length 且实际输出 < 原始 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 压缩参数 → CompactionSettings(1: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 > contextWindow(z.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 shouldCompact:tokens > 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
|
||||
@@ -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 bug:openai 客户端带 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
|
||||
# user(content 可以是 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].usage(Moonshot 系只放 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}, # 拿真实 usage(pi 同款)
|
||||
"temperature": model.temperature,
|
||||
}
|
||||
# 对照 pi buildParams:默认 max_completion_tokens(vLLM 兼容)
|
||||
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 被截断/损坏:保留 raw,arguments 置空,
|
||||
# 由循环层按「截断保护」路径处理(不执行残缺调用)
|
||||
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(锚点)+ timestamp(P0 时效校验)
|
||||
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 "",
|
||||
))
|
||||
|
||||
# 其他 role(system 等)防御性跳过
|
||||
return out
|
||||
@@ -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/number(python 里 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-8(python/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,
|
||||
),
|
||||
]
|
||||
@@ -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/AbortSignal(Web 标准)
|
||||
# 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 block(vLLM: 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 行的 id(build_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 → 8s(base × 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 的 contextWindow;max_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
|
||||
@@ -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 全部重新生成 ID,parent_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 去粉碎文件
|
||||
@@ -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
|
||||
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
core/llm_engine.py
|
||||
==================
|
||||
Qt 胶水层 —— 把 core.agent(pi 1:1 核心)接到 PyQt6 信号系统。
|
||||
|
||||
🌟 对照 pi 架构:
|
||||
AgentWorker(QThread)
|
||||
内部持有 Agent + AgentRunner(对照 pi 的 Agent + AgentSession 会话层)
|
||||
run() = pi 的 _runAgentPrompt:prompt → 循环 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: 收到精确 usage(UI 上下文标签 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: 思考字段优先级对照 pi(reasoning_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))
|
||||
@@ -0,0 +1,406 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WebView2 后端(Windows 首选浏览器内核,失败自动回落 QtWebEngine)。
|
||||
|
||||
设计要点(来自 P1 实验 tests/_tmp_wv2_demo.py 的实测结论):
|
||||
1. pythonnet + WebView2 .NET SDK(vendor/webview2/ 内 net462 Core.dll + webview2loader_x64.dll)
|
||||
2. 必须 OleInitialize(STA)后才能 CreateAsync
|
||||
3. 本机的 SDK 怪癖:传任何非空 user_data_folder 都报 RuntimeNotFound → 一律用默认 profile(ud=None)
|
||||
4. 残留 msedgewebview2.exe 会锁默认 profile(0x800700AA)→ 初始化前 taskkill
|
||||
5. .NET 版 CoreWebView2 不暴露子窗口 HWND → EnumChildWindows 找 Chrome_WidgetWin_* 类
|
||||
6. 子窗口天然是 Qt 顶层窗口的子 HWND,不需要 QWindow.fromWinId 包装(对子窗口会失败),
|
||||
由 Qt 布局算 slot 矩形后用 SetBoundsAndZoomFactor 驱动(父窗客户区物理像素)
|
||||
7. JS→Python 用 WebMessageReceived(JSON),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 变 disposed(set_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) # STA(COM 初始化要求)
|
||||
# 清残留浏览器进程(锁默认 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-forget(ChatBridge.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
|
||||
@@ -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()
|
||||
@@ -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 │
|
||||
└───────────────┬──────────────────────────────────────────────────────────┘
|
||||
│ QWebChannel(chat_bridge.py:Python → 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.py(SQLite:会话树 / 消息 / 压缩标记 / 附件) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**一句话数据流**:
|
||||
`输入框 → 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 SDK:net462 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`**。
|
||||
@@ -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
|
||||
|
||||
# ---- 渲染回退:QtWebEngine(WebView2 不可用 / 无头 / 多实例时自动使用)----
|
||||
# 注:WebView2 是首选渲染器,但它依赖微软运行时 + vendor/webview2/ 里的 .NET SDK,
|
||||
# 不走 pip;QtWebEngine 是本项目自带的兜底渲染路径,必须装。
|
||||
PyQt6-WebEngine==6.10.0
|
||||
PyQt6-WebEngine-Qt6==6.10.2
|
||||
|
||||
# ---- 大模型 SDK(OpenAI 兼容接口;DeepSeek / opencode-go / 自建反代均走它)----
|
||||
openai==2.26.0
|
||||
|
||||
# ---- WebView2 绑定:pythonnet(import 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 SDK(Core.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
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 + assistant,assistant 带代码块 + 工具时间线,贴近真实负载)
|
||||
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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 看门狗(200ms,forceRenderNow)
|
||||
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 B(timers 跑过之后): 兜底应已渲染正文 + 收尾 + 其余特性
|
||||
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()
|
||||
@@ -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] 打印)=====")
|
||||
@@ -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}';")
|
||||
|
||||
# 阶段 1(A 前台):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():
|
||||
"""切到 B:A 的流继续在后台跑"""
|
||||
print("[test] 切换到 B ...", flush=True)
|
||||
window.load_messages_to_web(S["B"])
|
||||
# 阶段 2(A 后台):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()
|
||||
@@ -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)
|
||||
@@ -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_context(P0 修复验证:不再有 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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"]])
|
||||
|
||||
# ---------- T2:build_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))
|
||||
|
||||
# ---------- T3:kind 传播 → 二次压缩走迭代摘要 ----------
|
||||
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)
|
||||
|
||||
# ---------- T10:insert_compaction_mark 自环守卫 ----------
|
||||
bad = db.insert_compaction_mark(sid, "S", r5["id"], r5["id"])
|
||||
check("T10.cut==retained 拒绝插入(防自环)", bad is None)
|
||||
|
||||
# ---------- T11:get_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)))
|
||||
|
||||
# ---------- T7:pre_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))
|
||||
|
||||
# ---------- T9:UI 渲染过滤排除 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)
|
||||
|
||||
# ---------- T12:Fix 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)
|
||||
|
||||
# ---------- T13:Fix 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)
|
||||
|
||||
# ---------- T14:G2 — 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'),
|
||||
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);
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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 越界 → 明确提示(旧版'已显示 999–998 行')",
|
||||
"超出文件范围" 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)
|
||||
@@ -0,0 +1,129 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WebView2 守卫单测 —— 锁死 T0 级事故不再复发
|
||||
|
||||
事故链(已发生):
|
||||
每个 MainWindow()(含 offscreen 测试)→ get_environment()
|
||||
→ 无条件 `taskkill /F /IM msedgewebview2.exe`
|
||||
→ 把【正在运行的生产 app】的 WebView2 浏览器进程一并杀掉
|
||||
→ 它的 controller 变 disposed(set_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)
|
||||
@@ -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()
|
||||
@@ -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 级 setFont(QSS ::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()
|
||||
@@ -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-mathml(CSS 视觉隐藏的 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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.log(USER/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
|
||||
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
系统级工具集合 (system_tools)
|
||||
- screen_capture: 屏幕截图覆盖层
|
||||
- global_hotkey: 全局热键监听线程
|
||||
- file_reader: 文本/代码文件读取(编码探测 + 二进制探测)
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 双路 SetBoundsAndZoomFactor(slot 矩形 → 父窗客户区物理像素)
|
||||
- WebMessageReceived → ChatBridge 的 JS→Python 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)
|
||||
@@ -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}
|
||||
@@ -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 之前执行)
|
||||
双协议:WebView2(window.chrome.webview.postMessage)/ QtWebEngine(QWebChannel) -->
|
||||
<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>
|
||||