Files
Haocode/ARCHITECTURE.md
T
2026-09-17 16:30:02 +08:00

14 KiB
Raw Blame History

项目文件架构

Warning

历史资料,不是当前事实源。 本文仅保留旧架构与故障记录。处理修复、跨平台、测试、项目结构或交接任务时,先读 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 精确语义)
  • 每次发送 → 新 AgentWorkercore/llm_engine.pyQThread 胶水层); 多轮工具循环在一次发送内部闭环,UI 线程只收 Qt 信号。
  • stream_fn 协议:stream_fn(context, model, signal, max_tokens, tools=None) tools 必须序列化进 OpenAI 请求体(否则模型只能"文字扮演"工具调用)。
  • SYSTEM_PROMPT.md(项目根):worker 模式每次请求头部注入,不入历史、不受压缩影响。

上下文管理与压缩(2026-07-21 升级为 pi harness 原版算法 1:1

对照 packages/agent/src/harness/compaction/compaction.ts + utils.ts 逐函数移植:

  • 触发公式shouldCompact: tokens > contextWindow reserveTokens reserveTokens=16384AgentConfig.compaction_reserve),替换旧 40% 启发式。
  • token 估算:usage 锚定 1:1——最后一条有效 assistant(非 aborted/error 且 totalTokens>0)的精确值 + 其后消息逐条估算;无 usage 则全部逐条。 逐条估算用 CJK 感知版(唯一已声明偏差:pi 是 chars/4)。
  • 切分点 find_cut_point:从尾部回扫累计 token 至 keepRecentTokens=20000, 取该位置起第一个有效切点(user/assistanttoolResult 不可切); 切点非 user → 断轮:轮起点前的历史与轮前缀分两次 LLM 摘要 拼接 {history}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix}
  • 迭代式更新:上次压缩摘要(kind="compaction_summary" 消息)不重摘, 作为 <previous-summary>UPDATE_SUMMARIZATION_PROMPTpi 四套提示词逐字移植)。
  • 文件操作附录:摘要范围内 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 信号按到达顺序累积。
  • 入库:正常结束 / 中止 两条路径都带 timelinedb.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. 同步渲染通道 syncRenderThrottledappendToken/appendReasoning 在同一 JS 任务内 30ms 节流直接写 DOMtoken 由 Python runJavaScript 送达,必被执行);
  2. 恢复路径同步上屏renderTimelineEntries(liveMode) 结束即 syncRenderThrottled(msgId, 0) —— 中途切回会话,恢复的正文/思考 不等任何帧立即显示;
  3. Qt 看门狗MainWindow._render_watchdog200ms):对当前会话 活跃流 forceRenderNow(msgId)(幂等,尾部无变化时近零开销)。

另有:doStreamingRender 逐段 try/catch(解析异常 → 全量重解析); rAF 通道保留作平滑优化 + 40ms setTimeout 兜底;收尾取消全部定时器。

回归测试:smoke_live_guard.py Phase 0rAF+全部页面定时器 kill → burst 后 400ms 内容必现)+ Phase A(仅 rAF 卡死 → 40ms 兜底); smoke_midswitch.py(流式中切走→切回:块顺序/正文/思考/DB 完整 + 切回后立即同步可见)。

2. 深度思考:默认收起 + 进行中蓝色动画

  • 流式/恢复/历史三条路径的思考块一律 open=false
  • 进行中块带 .streaming-think:标签蓝色呼吸 + 省略号动画(CSS think-breathe / think-dots);
  • 收尾移除动画类,标签还原「已完成深度思考」。

3. bash chip:耗时 / 命中超时徽章

  • bash 工具结果自带 [exit N] (X.Xs)命令超时(>Ns)已终止
  • _parseToolTiming 解析后在摘要行插入 ⏱ X.Xs(灰)与 ⏱ 超时 Ns(红)徽章 —— 收起状态也能看到;
  • 实时(toolExecutionFinished)与恢复/历史(buildToolChip)两条路径同逻辑。

4. 工具结果:展开必显示 + 长结果尾部预览 + 展开按钮

  • 修复旧 bugtoolExecutionFinished 原来只在 chip.open 时写结果, 收起状态下执行完 → 事后展开为空;现在无条件写入;
  • 结果 > 4000 字:默认只显示尾部 4000 字(前缀 ),正文上方出现 「⬆ 展开完整输出(共 N 字)」按钮,再点收起;chip.__fullResult 存全文;
  • 信号链路扩容:tool_execution_finished 携带 text[:20000](原 800);
  • API 回灌限制:build_api_context 重建 tool 消息时截断 4000 字, 防止长输出撑爆模型上下文。

流式正文不显示:深度调试与根因(2026-07)

现象链

旧版(逐 token 全量 innerHTML 同步重绘)正文可见但乱序 → 时间线重构后(缓冲+rAF 增量渲染)正文流式期不显示,结束后/切回后可见。

诊断数据(用户环境 stream_diag.log,前端 v2

  • FRONTEND_VER 20260721-v2 ✓ 最新前端在跑
  • CHUNK/THINK 逐 token 到达 UI 线程,会话匹配 ✓
  • JS_DIAG 完成时刻环形缓冲 = 思考事件以 ~95/s 流入 → JS 线程活着,token 在被处理
  • 结论:不是 JS 没跑,是屏幕没有把 DOM 变化画出来

根因(本机复现)

AMD 核显 GPU 上下文周期性丢失:

  • --enable-gpu-rasterization --ignore-gpu-blocklist(旧默认)离屏实测: 连续 SharedImageStub: context already lost 错误 → 页面 JS 处理/Qt 定时器全部停摆
  • --disable-gpu 后同样代码 100% 正常(真实消息内容回放探针 b/d 双满)
  • 用户屏幕上的部分停摆:DOM 已写入,但 GPU 光栅化产物无法合成上屏 → 正文不可见

修复

  1. main.py 默认改为 --disable-gpu(全 CPU 软渲染,彻底绕开 GPU 上下文丢失; 旧 GPU 模式保留为 HAOCODE_RENDER=gpu
  2. 保留双保险渲染通道(同步节流渲染 + 200ms Qt 看门狗 forceRenderNow
  3. v3 探针:probeStream() 每 2s 写入 PROBE 日志(缓冲长度/DOM 长度/offsetHeight/opacity/display), dumpDiag 改为按类型摘要 —— 若再异常可一次性定位断点

最终根因(2026-07-21 v7,推翻上述 GPU 主因结论)

人肉 debugtoken 级日志 + 屏幕截图 + 逐层盒模型探针 + 页面内对照/克隆实验)锁定:

Chromium 布局失效 bug:空 .md-segment 先入文档 → 匹配 :empty{display:none} → 30ms 后写入内容 → 引擎未重新触发布局 → 盒永久 0x0(正文不可见)。

证据链:

  1. token 日志:正文 段bufdomtextContent)同步增长(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-taildisplay:contentsblock(规避 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 后正文段体检日志