Add docs/agent-handoff (backlog, execution state, verification, platform plan, per-task evidence), repo AGENTS.md, and architecture notes updated for the dual-renderer design.
239 lines
14 KiB
Markdown
239 lines
14 KiB
Markdown
# 项目文件架构
|
||
|
||
> [!WARNING]
|
||
> **历史资料,不是当前事实源。** 本文仅保留旧架构与故障记录。处理修复、跨平台、测试、项目结构或交接任务时,先读 [`docs/agent-handoff/README.md`](docs/agent-handoff/README.md)。
|
||
|
||
> 仅描述目录与文件的基础组织,不涉及具体实现细节。
|
||
|
||
```
|
||
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 后正文段体检日志
|
||
|