Files
Haocode/Frame.md
T
sorrow404null a7412824e0 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.
2026-09-17 16:40:01 +08:00

289 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Frame —— haocode 逐目录 / 逐文件说明
> 本文件是 `readme.md` 的下钻版:**每个文件夹、每个文件到底负责什么**,
> 以及关键的类 / 函数名(方便直接搜索定位)。
> 阅读建议:先看 `readme.md` 的架构总览,再回来看这里的目录细节。
---
## 0. 一眼看清分层
| 层 | 目录 | 职责 | 能否单独跑 |
|---|---|---|---|
| 入口 | `main.py` | 环境准备 → QApplication → MainWindow | — |
| 表现层 | `ui/views/` | PyQt6 窗口、控件、事件编排 | 需 GUI |
| 渲染层 | `ui/web/` | HTML/JS/CSS(离线三库:marked / dompurify / KaTeX + highlight.js | 浏览器可单独开 |
| 引擎层 | `core/llm_engine.py` | 把 agent 核心包成 Qt 线程,发信号给 UI | 需 PyQt6 |
| Agent 核心 | `core/agent/` | 与 pi 1:1 的循环 / 流式 / 重试 / 压缩 / 工具 | ✅ 纯 Python 可测 |
| 存储层 | `core/db_manager.py` | SQLite:会话树、消息、压缩标记、附件 | ✅ 纯 Python 可测 |
| 外部集成 | `core/webview2.py``vendor/webview2/` | WebView2 原生子窗口 + .NET SDK | 需 Windows |
| 资源 | `svg/``ui/web/katex|highlight``data/` | 图标 / 前端库 / 配置 | — |
---
## 1. 根目录文件
| 文件 | 说明 |
|---|---|
| `main.py` (111 行) | **程序入口**。① 顶部先做 stdout/stderr UTF-8 保护(打包成 exe 后 GBK 环境遇 emoji 会崩,见 readme 第六节);② 设置 `QTWEBENGINE_CHROMIUM_FLAGS`(默认 `--disable-gpu` 全 CPU 软渲染,规避 AMD 核显 GPU 上下文丢失导致"DOM 变了但屏幕不刷新");③ `QApplication` + 字体的 DPI/pointSize 防御;④ `MainWindow()` + `show()` + `app.exec()`。 |
| `SYSTEM_PROMPT.md` (~3400 tokens) | **agent 系统提示词**。0 身份 / 1 运行环境(含 §1.1 cmd.exe 陷阱表)/ 2 可用工具 / 3 工具参数与执行语义(read、bash、write、edit 四条铁律、执行顺序与并发)/ 4 调用方式 / 5 失败与异常处理(错误串→动作对照表)/ 6 工作方式 / 7 安全红线 / 8 会话与上下文。**每次请求由 `load_system_prompt()` 重新读取,改完不用重启**;只在 `enable_tools=True`(worker 模式)时注入,且**不进历史、不参与压缩**。 |
| `requirements.txt` | 依赖清单(只列代码真实 import 的包,AST 扫描核对过)。 |
| `haocode.spec` | **PyInstaller 打包配置**onedir)。关键点:把 `ui/web``svg``vendor/webview2``SYSTEM_PROMPT.md``data/config.json``WebView2Loader.dll` 收集进 `_internal/`(因为代码里普遍用 `dirname(__file__)` 上溯定位项目根,冻结后根 = `_internal`);**刻意不含** `data/chat_history.db``data/attachments/``console=False`;带 `runtime_hooks`。 |
| `pyi_rth_trace.py` | 打包**运行时钩子**:把冻结版 stdout/stderr 重定向到 exe 同级 `haocode.log`(GUI 版没有控制台,没有它启动异常会完全静默)。 |
| `WebView2Loader.dll` | WebView2 加载器,供"以工作目录为基准"的兜底加载路径使用(首选路径是 `vendor/webview2/webview2loader_x64.dll`)。 |
| `ARCHITECTURE.md` / `readme_our.md` / `黑边两现象分析报告.md` | **历史文档**(早期架构、早期设计稿、窗口缩放黑边问题分析)。其中提到的 `agents/``workspace/``ui/assets/``untitled.ui` 等目录/文件**已在交接版移除**,最新结构以 `readme.md` + 本文件为准。 |
| `_out.txt` / `_t0.txt` | 文本示例文件(按交接要求保留)。 |
---
## 2. `core/` —— 后端核心
### 2.1 `core/db_manager.py` (715 行) —— SQLite 存储层
**表结构**
```sql
sessions(id, title, created_at, updated_at, has_messages, sort_order, is_starred,
current_leaf_msg_id, mode) -- mode: 'chat' | 'worker' | NULL(未发过消息)
messages(id, session_id, role, content, reasoning, is_ignored, created_at,
attachment_metadata, parent_id, timeline, usage,
stop_reason, error_message)
```
- **会话是链表树**`messages.parent_id` 指向上一条,`sessions.current_leaf_msg_id` 记录当前叶子。
`get_message_chain()` 从叶子向上回溯再反转 → 得到"当前激活时间线"(绕开所有废弃分支)。
- `role` 取值:`user` / `assistant` / `system` / **`compaction`**(压缩标记行,`content` 是摘要)。
- `timeline`assistant 行的**事件时间线 JSON**`{"t":"think"|"text"|"tool", ...}`),
工具条目形如 `{"t":"tool","id","name","args","ok","result"}``ok=None` 表示开始了但没拿到结果。
- `usage`:本轮精确 token 用量(`{"input","output","cacheRead",...}`),供显示与压缩估算做**锚点**。
- `stop_reason` / `error_message`:失败轮次标记(`'error'`),NULL = 正常行。
**关键方法**
| 方法 | 作用 |
|---|---|
| `add_message(...)` | 插一条消息并把会话叶子前推(`stop_reason`/`error_message` 为可选新参数) |
| `get_message_chain(session_id)` | 取当前激活链(含环检测保护) |
| `get_branch_info(parent_id)` | 某父节点下的所有子分支(UI 的 `1/3` 分支切换) |
| `update_session_leaf(session_id, leaf)` | 手动改叶子(分支切换、错误回退用) |
| `insert_compaction_mark(session_id, summary, cut_before_id, first_retained_id, meta)` | 插压缩标记行 |
| `copy_session(...)` | 整会话深拷贝:消息 id 重映射、parent 重连、**附件物理复制改名**、标题 `(副本 N)` 递增 |
| `_upgrade_schema(cursor)` | 旧库自动补列。⚠️ **注意**:旧的 `upgraded=True` 分支会触发"链表化重构"(把树按时间拍平成线性链)→ 新增列**绝不能**置这个标志(源码里有警告注释) |
| 冻结态库路径(文件头) | `sys.frozen` 时优先 `exe/../../data/chat_history.db`(存在才共用),否则 `exe 同级/data/chat_history.db` |
### 2.2 `core/llm_engine.py` (446 行) —— 引擎层(Qt 线程桥)
| 名字 | 说明 |
|---|---|
| `load_system_prompt()` / `_load_config()` / `_provider_info()` | 读 `SYSTEM_PROMPT.md``data/config.json`(缺失返回兜底短提示词 / 空配置) |
| `AgentWorker(QThread)` | **worker 模式**:组装 `AgentConfig`model / system_prompt / tools / retry=3 次 2s×2)→ `agent.set_stream_fn(openai_stream)``AgentRunner(agent, summarize_fn=..., 回调...)``runner.run(last_user)`。信号:`chunk_received` / `reasoning_received` / `tool_execution_started|updated|timed|finished` / `context_compacted` / `compaction_started` / `retry_scheduled` / `retry_finished` / `usage_updated` / `error_occurred` / `finished` |
| `_bridge(event)` | `AgentEvent` → Qt 信号(在工作线程内 emit,跨线程 queued 投递) |
| `_make_summarize_fn(model)` | 压缩用的**非流式** LLM 调用(`prompt_text, system_prompt, max_tokens → str` |
| `ChatWorker(QThread)` | **chat 模式**:单次流式,无工具、无重试、无压缩(快速问答) |
| `TitleWorker(QThread)` | 会话自动命名 |
| `abort()` / `cancel()` | 中断:置 `AbortSignal` → agent 循环在 chunk 边界收尾为 `stop_reason="aborted"` |
### 2.3 `core/webview2.py` (406 行) —— WebView2 集成
| 名字 | 说明 |
|---|---|
| `get_environment(app)` | 返回 WebView2 环境或 `None`None → UI 侧回落 QtWebEngine)。**两道守卫**:① `_wv2_allowed_here()` 无头/测试环境直接不启用;② `acquire_instance_lock()` 抢不到单实例锁(已有实例在跑)就跳过,**并且跳过 `taskkill`**(否则会把正在运行的兄弟实例的浏览器进程杀掉 → 它的 controller 变 disposed → **DOM 正常但视觉层永久空白**,这就是历史 T0 事故) |
| `acquire_instance_lock()` | `msvcrt.locking``data/app_instance.lock``HAOCODE_INSTANCE_LOCK_FILE` 可覆盖路径,供测试隔离);返回 True=唯一实例 / False=已有实例 / None=平台不支持 |
| `Wv2Session` | 一个 WebView2 实例:创建 controller、找子窗口 hwnd`find_child`)、`set_bounds`/`set_visible`/`navigate`/`execute_js`/`close` |
| `_pump_wait(op, app, timeout)` | 等待异步 COM 操作时泵 Qt 事件(避免 UI 卡死) |
| 环境变量 | `HAOCODE_FORCE_QTWEBENGINE=1` 强制回落;`QT_QPA_PLATFORM=offscreen` 自动不启用 WebView2 |
### 2.4 `core/debug_log.py` (78 行) —— 调试事件总线
`debug_log(msg, tag)` 写内存环形缓冲 + 落盘;`poll_debug_cmd()` 取调试窗口输入的命令;
`autostart_debug_window(cfg)``config.json: debug_window_autostart` 决定是否开调试窗口。
### 2.5 `core/agent/` —— pi 1:1 agent 核心(9 文件)
| 文件 | 行数 | 关键名字 | 说明 |
|---|---|---|---|
| `types.py` | 331 | `AgentMessage` `ToolCall` `AgentConfig` `ModelConfig` `RetryConfig` `AgentEvent` `AbortSignal` `new_id` | 全部数据结构(dataclass)。`AgentMessage``content/reasoning/tool_calls/stop_reason/error_message/usage/timestamp` |
| `agent.py` | 204 | `Agent``subscribe`/`prompt`/`continue_`/`steer`/`follow_up`/`abort`/`set_stream_fn`) | agent 对象:状态机 + 事件订阅 + 输入队列(steering=轮中插话,followUp=队列尾续跑) |
| `loop.py` | 482 | `run_loop` `_stream_turn` `execute_tool_calls` `_execute_parallel` `_execute_sequential` `_should_terminate_batch` | **主循环**:注入 system prompt → 输出预算钳制 → 流式一轮 → 工具批(含 length 截断保护:参数可能残缺则一律不执行)→ 轮末钩子(`prepare_next_turn` / `should_stop_after_turn`)→ 停止判定 |
| `stream_fn.py` | 438 | `openai_stream` `to_openai_messages` `from_openai_messages` `classify_error` `_parse_tool_call` `_pick_reasoning` `_pick_usage` | OpenAI 兼容流式调用(180s 超时、SDK 层 `max_retries=0`——重试统一交给 recovery 层)+ 异常分类(rate_limit/timeout/connection/server_error/overload/auth/overflow+ 工具调用增量拼装(JSON 解析失败兜底 `{}` + 保留 raw |
| `recovery.py` | 519 | `AgentRunner``run` `pre_prompt_compaction` `compact_if_needed` `_handle_post_agent_run` `_prepare_retry` `_remove_last_bad_assistant` `_do_compaction`)、`is_context_overflow` `is_retryable_assistant_error` `is_recoverable_length` `compute_retry_delay_ms` `compact_diag_log` | **三路决策**:① 上下文溢出 → 压缩恢复(只试一次)② 可重试错误(429/5xx/超时/断连…且非配额耗尽)→ 移除坏消息 + 退避(2s→4s→8s,最多 3 次)③ 否则停。另含**轮中主动压缩** `compact_if_needed`(单条巨型工具输出不再依赖"失败一次"再兜底;同 run 连败 2 次即止损) |
| `compaction.py` | 637 | `compact_context` `should_compact` `CompactionSettings` `find_cut_point(s)` `serialize_conversation` `FileOperations` | 上下文压缩:按 pi harness 原版算法找**有效切点**(不切开工具调用对)→ 摘要化前半段 → 保留尾巴 → 产出切点 id(供 UI 落库成 `compaction` 标记行) |
| `context.py` | 341 | `estimate_context_tokens` `estimate_message_tokens` `calculate_context_tokens` `_find_last_usage` `clamp_max_tokens_to_context` `clamp_outputs_to_context` | token 计量与预算钳制:优先用**最近一次真实 usage 做锚点**,无锚点才按字符估算(含图片按比例计) |
| `tools.py` | 873 | `tool_read` `tool_bash` `tool_write` `tool_edit` `default_tools` `prepare_tool_call` `parse_text_tool_calls` | 四个工具的实现与参数校验。`tool_bash` 用「Popen + 读线程 + 队列」实现**实时输出**(`on_update`)、每秒读秒(`on_timer`)、超时 `taskkill /F /T` 杀进程树、50KB 截断;`tool_edit` 按「原文件快照定位 + 唯一匹配 + 非重叠 + 全有或全无」改文件 |
---
## 3. `ui/` —— 表现层
### 3.1 `ui/views/main_window.py` (6019 行) —— 主窗口(全项目核心)
单文件承载了绝大部分 UI 与编排逻辑,按区块读:
| 区块(搜关键字) | 说明 |
|---|---|
| `MainWindow.__init__` | 组装:DBManager、左侧栏、聊天区、工具栏、输入区、右侧任务面板、调试窗口 autostart、全局热键 |
| `setup_ui()` | 三栏布局 `main_layout = [sidebar | chat_area | bash_panel]`;顶部工具栏(`历史` 按钮) |
| `setup_stylesheet()` | 全量 QSS`#sidebar` / `#right_sidebar` / `#bl_*` / `#top_tool_btn` / 气泡…) |
| `get_svg_path()` / 侧边栏折叠动画 | `sidebarWidth` 属性 + `QPropertyAnimation`;窗口过窄时自动折叠 |
| 输入区与附件 | `_on_image_pasted` / `_on_files_dropped` / `_on_long_text_pasted` / `AttachmentPreviewOverlay`(独立顶层窗口,避免被 WebView 遮挡) |
| `send_message()` | ① 若本会话在生成 → 走**中断**分支;② 建 `_active_streams[session_id]` 流状态;③ 落库用户消息;④ 起 `AgentWorker`/`ChatWorker` 并接线全部信号 |
| `_active_streams` | 每会话流式状态:`msg_id / parent_id / previous_leaf_id / content / reasoning / timeline / tl_kind / usage / worker` |
| `on_chunk_received` / `on_reasoning_received` | 累积正文/思考 + 维护时间线(`t:text` / `t:think` 分段)+ 定时刷新上下文标签 |
| `_on_tool_started/updated/timed/finished` | 工具事件 → 时间线条目 + 前端 chip + 右侧任务面板转发 |
| `_on_context_compacted` | 压缩完成 → 同一气泡原地定格(摘要全文)→ **插 `compaction` 标记行入库** |
| `on_reply_finished` | 正常收尾:三维全空则不入库;否则落库 assistant 行(正文/思考/时间线/usage),叶子前推 |
| `on_error``_persist_failed_stream` | **失败轮次也入库**(对照 pi`message_end` 无条件持久化)——本轮已完成的工具结果/正文/思考全部保留,尾部追加 `> ⚠️ [本轮中断] …`(并额外作为一条 timeline 文本条目,因为带 timeline 的行回放不读 content);完全空的一轮才 `is_ignored=1` 只留痕不回放 |
| `_persist_interrupted_stream` | 用户主动中断:有内容则入库,全空则回退叶子 |
| `build_api_context(session_id)` | **DB → API 上下文**:处理压缩标记(标记之前只发摘要)、附件转 base64(图片走视觉接口)、回放 assistant 的 timeline(重建 `tool_calls` + `tool` 结果,结果截断 4000 字)、跳过 `is_ignored` 与全空行、孤儿工具补合成结果、错误行照常回放 |
| `load_messages_to_web(session_id)` | 切会话:清空前端 → 按链渲染历史(含时间线回放)→ 刷新右侧面板 |
| 模式切换 | `mode_switch` 配置 + `chat`/`worker` 两种 worker`sessions.mode` 锁定已发过消息的会话模式 |
| 右侧面板接线 | `bash_panel.set_session(...)` + 四个 tool 事件转发(仅当前会话) |
| 关闭清理 | `closeEvent`:中断所有 worker、持久化进行中的回复、清理后台任务 |
### 3.2 `ui/views/bash_panel.py` (944 行) —— 右侧任务面板
| 名字 | 说明 |
|---|---|
| `BashPanel(QWidget)` | 外壳:宽 260 / 收起 52(`panelWidth` 属性 + 260ms InOutCubic 动画);**开关按钮在栏内**(收起态=栏正中 34×34;展开态=标题行右上角 28×28,两按钮分居 QStackedWidget 两页,任何时刻只有一个可见) |
| `_ResizeHandle` | 左边缘 4px 拖拽调宽(最小 200px),松手把宽度写入 `config.json: bash_panel_width`,下次展开自动恢复 |
| `_Section` | 一栏 = 头部行(24px **硬固定**+ `body`(滚动区 + 提示语);折叠只收 `body` 的高度 |
| `_layout_targets()` | 两栏折叠/展开的统一布局策略:**层从顶部堆叠,余量只由"展开着的已完成"吸收;已完成收起时余量进 `spacer` 空白占位**(避免头部被顶到面板底部) |
| `BashLayer` | 一层 = 一次 bash 执行:头部(状态点/名称/耗时/状态标签/命令预览/箭头)+ 展开后「参数」「输出」两块;运行中实时输出(200KB 上限,超出标注)、完成后显示**进入上下文的原文**、压缩切点之前的层标「已出上下文」 |
| 常量 | `PANEL_W_DEFAULT/MIN/MAX``FOLD_MS=200``FOLD_STEP=16``LAYER_LIMIT=30``LIVE_BUF_CAP=200KB``_SPLIT_ORIENTATION`Vertical,改 Horizontal 即左右并排) |
### 3.3 `ui/views/` 其余文件
| 文件 | 行数 | 说明 |
|---|---|---|
| `chat_bridge.py` | 188 | `ChatBridge(QObject)`**Python → JS 单向桥**QWebChannel)。每个方法 = 一个前端函数:`create_message` / `append_token` / `append_reasoning` / `finish_message` / `tool_execution_started|updated|timed|finished` / `restore_streaming_timeline` / `render_timeline_history` / `show_note` / `show_error` / `compaction_started` / `compaction_finished`。用 `json.dumps` 转义全部文本,杜绝注入 |
| `wv2_view.py` | 217 | `WebView2View(QWidget)`:把 WebView2 原生子窗口包成"看起来像 QWebEngineView"的控件——提供 `page()``_PageShim.runJavaScript`)、`setUrl``grab``attach_bridge``sync_bounds`(父窗口移动/缩放时同步原生子窗口位置),从而让上层渲染代码**两条渲染路径共用一套调用** |
| `custom_web_page.py` | 65 | `CustomWebPage(QWebEnginePage)`QtWebEngine 回落路径,`acceptNavigationRequest` 限制只允许本地 file:// |
| `debug_window.py` | 214 | `DebugWindow(QWidget)`:两个页签「对话消息 / 应用日志」+ 命令输入框;`_TailReader` 增量读日志文件 |
### 3.4 `ui/views/system_tools/`
| 文件 | 说明 |
|---|---|
| `file_reader.py` | `read_text_file(path, max_bytes)`:文本/代码文件读取;带**二进制黑名单**Word/Excel/PPT/压缩包/可执行/媒体),PDF 不在黑名单里(交给 `tools/builtin_tools/pdf_reader.py` |
| `global_hotkey.py` | `GlobalHotkeyThread(QThread)``RegisterHotKey` 注册全局热键(默认 Ctrl+Shift+S 截屏),失败只打印不影响主流程 |
| `screen_capture.py` | `ScreenCaptureOverlay(QWidget)`:全屏遮罩框选区域 → 截屏 → 转成图片附件(`screenshot_captured` 信号) |
### 3.5 `ui/web/` —— 本地渲染层(完全离线)
| 文件 | 行数 | 说明 |
|---|---|---|
| `index.html` | 82 | 页面骨架 + 引入本地 `marked` / `dompurify` / `highlight` / `katex`(无任何 CDN |
| `app.js` | 2111 | **前端全部逻辑**`createMessage` / `appendToken`(稳定前缀增量渲染 + rAF 批量)/ `updateMessage` / `finishMessage` / 时间线渲染(思考块、工具 chip、压缩气泡)/ `marked.parse` 统一拦截点(先抽公式占位符再渲染,最后 KaTeX 回填)/ 自动滚动与"贴底"判定 / 分支切换 `1/3` |
| `style.css` | 902 | 全部样式:气泡(assistant 85% 宽、透明正文)、思考块、工具 chip、紧凑模式、公式块、错误提示条 |
| `marked.min.js` / `dompurify.min.js` | — | Markdown 渲染 + HTML 消毒 |
| `highlight/` | — | highlight.js + atom-one-dark 主题 |
| `katex/` | — | KaTeX 0.16.11js + css + woff2 字体) |
---
## 4. `tools/`
| 文件 | 说明 |
|---|---|
| `builtin_tools/pdf_reader.py` | **PDF 专用解析**`extract_pdf_text` / `extract_pdf_images`),基于 PyMuPDF。被 `ui/views/main_window.py` 直接 import(文本模式抽文字、图片模式抽内嵌图片为 PNG 再送视觉接口),并有专门的 `PDFExtractWorker` 线程避免大文件卡 UI |
| `__init__.py` / `builtin_tools/__init__.py` | 包结构标记(**必须保留**,主窗口按 `tools.builtin_tools.pdf_reader` 路径 import |
> 交接版已删除该目录下**全空且零引用**的 `registry.py` / `conda_env.py` / `file_ops.py` / `web_search.py`。
---
## 5. `svg/` —— 图标(18 个)
`panel.svg`(左侧栏开关)/ `panel_right.svg`(右侧任务面板开关)/ `chevron_down|right.svg`(弹窗箭头)/
`mode_chat.svg` / `mode_worker.svg`(模式)/ `model.svg` / `provider*.svg`(模型弹窗)/ `send*.svg` / `stop*.svg` /
`upload.svg` / `cross.svg` / `check.svg` / `main.svg`。全部经 `MainWindow.get_svg_path()` 按项目根解析。
---
## 6. `data/` —— 运行时数据
| 文件 | 说明 |
|---|---|
| `config.json` | 供应商/模型/开关(**交接版已清空 api_key**)。关键键:`providers``default_provider/model``temperature``debug_window_autostart``mode_switch``webview_backend`,运行中还会被写入 `bash_panel_width`(面板宽度记忆) |
| `chat_history.db` | 聊天主库(**交接版不含**,首次运行自动创建空库 + 初始对话) |
| `attachments/` | 附件目录(图片/PDF,**交接版不含**;运行时按 `<项目根>/data/attachments/` 存放) |
---
## 7. `vendor/webview2/`
WebView2 的 .NET SDK(随仓库提供,不走 pip):
`net462_Microsoft.Web.WebView2.Core.dll``clr.AddReference` 加载)、`webview2loader_x64.dll`loader)、`sdk.nupkg`(原始包)。
---
## 8. `tests/` —— 测试与调试工具
### 8.1 正式测试套件(交接后应保持全绿)
| 文件 | 内容 |
|---|---|
| `run_tests.py` | **离线 harness**(自带用例收集 + pytest stub),跑 `test_agent_core.py` |
| `test_agent_core.py` | agent 核心:流式增量、工具批、重试、压缩、读秒、超时杀树 |
| `test_tool_params.py` | 四个工具的参数校验与错误串(35 项) |
| `test_compaction_persist.py` | 压缩标记入库 + 上下文截断行为 |
| `test_copy_session.py` | 会话复制(消息/分支/附件深拷贝/标题递增/叶子)(54 项) |
| `test_bash_stream.py` | bash 实时输出、每秒读秒、超时杀进程树、50KB 截断、非零退出 |
| `test_error_persist.py` | **失败轮次入库 + 回放取舍**(T1–T9,含旧库迁移不得破坏分支的安全用例) |
| `test_wv2_guard.py` | WebView2 双守卫(无头环境 / 跨进程单实例锁) |
| `test_debug_window.py` | 调试窗口事件与日志 |
| `test_think_code_neutral.py` | 思考块/代码块内容中性化 |
| `test_file_attach.py` / `test_pdf_reader.py` | 附件类型判定 / PDF 文本与图片解析 |
| `test_math_extract.js` | 前端公式提取逻辑(Node 运行,39 项) |
| `smoke_offscreen.py` | 主窗口离屏冒烟(8 项) |
| `smoke_mode.py` | chat ↔ worker 模式切换(16 项) |
| `smoke_copy_session.py` | 复制会话 UI 全链路 |
| `smoke_bash_panel.py` | 右侧任务面板全链路(含折叠动画/布局策略/拖拽调宽,116 项) |
| `check_db_migration.py` | 只读工具:校验库迁移后旧数据 100% 原样(消息数/父指针/叶子/分支点逐项比对) |
### 8.2 真机 / 真 API 工具(按需使用,不属于回归)
`smoke_live_guard.py``smoke_midswitch.py``smoke_persist.py``smoke_probe.py``smoke_repro_real.py`
`smoke_timeline.py``smoke_manual.py``diag_live_agent.py``diag_live_onscreen.py``diag_live_text.py`
`verify_onscreen.py``verify_math_render.py``inject_math_demo.py``debug_inject.py`
`tune_mode_popup.py``tune_model_popup.py`(两个弹窗调参工具)。
### 8.3 写测试的两条铁律
1. 不得污染真实库:测试开始时把 `core.db_manager._DEFAULT_DB` 指向临时文件(**必须在 import MainWindow 之前**
2. 不得污染真实配置:把 `HAOCODE_CONFIG_FILE` 指向临时文件(会写 `config.json` 的功能)
> 交接版已清除 81 个开发期临时物(`_tmp*` 脚本 / `_tmp_resize_vis/` 截图 / `_tmp_*.log`)。
---
## 9. 一次提问的完整生命周期(把上面所有文件串起来)
```
① 用户输入(ui/views/main_window.py: send_message
└─ 落库 user 行(core/db_manager.add_message)→ 建 _active_streams[sid] 流状态
② 组装上下文(main_window.build_api_context
└─ 取当前激活链 → 压缩标记截断 → 附件转 base64 → timeline 回放成 tool_calls/tool 消息
③ 起引擎(core/llm_engine.AgentWorker.run
└─ AgentConfig(model, system_prompt=SYSTEM_PROMPT.md, tools=default_tools(), retry=3×2s)
+ AgentRunner(summarize_fn=..., on_retry_* / on_compaction_* 回调)
④ agent 核心(core/agent/loop.run_loop
├─ 轮首/轮中压缩检查(recovery.compact_if_needed → compaction.compact_context
├─ 流式一轮(stream_fn.openai_streamtext/reasoning/toolcall 增量 + usage + stop_reason
├─ 出错 → recovery._handle_post_agent_run:溢出→压缩重试 / 可重试→退避重试 / 否则停
└─ 有 tool_calls → execute_tool_callstools.py 的四个工具,并行/串行按批次规则)
⑤ 事件回流 UIllm_engine._bridge → Qt 信号 → main_window 的 on_* 处理器)
└─ chat_bridge 调前端 app.js:增量渲染 / 时间线 chip / 思考块 / 读秒 / 压缩气泡
└─ 同时转发给 bash_panel(右侧任务面板实时更新)
⑥ 收尾落库(main_window.on_reply_finished 或 on_error→_persist_failed_stream
└─ assistant 行 = 正文 + 思考 + timeline(工具) + usage(+stop_reason),叶子前推
└─ 压缩发生 → 另插 role='compaction' 标记行
⑦ 下一次提问回到 ②,此时模型能看到上一轮的正文、工具调用与结果(失败轮次亦然)
```