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

25 KiB
Raw Blame History

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.pyvendor/webview2/ WebView2 原生子窗口 + .NET SDK 需 Windows
资源 svg/、`ui/web/katex highlightdata/` 图标 / 前端库 / 配置

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=Trueworker 模式)时注入,且不进历史、不参与压缩
requirements.txt 依赖清单(只列代码真实 import 的包,AST 扫描核对过)。
haocode.spec PyInstaller 打包配置onedir)。关键点:把 ui/websvgvendor/webview2SYSTEM_PROMPT.mddata/config.jsonWebView2Loader.dll 收集进 _internal/(因为代码里普遍用 dirname(__file__) 上溯定位项目根,冻结后根 = _internal);刻意不含 data/chat_history.dbdata/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 存储层

表结构

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 是摘要)。
  • timelineassistant 行的事件时间线 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.mddata/config.json(缺失返回兜底短提示词 / 空配置)
AgentWorker(QThread) worker 模式:组装 AgentConfigmodel / 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
_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 环境或 NoneNone → UI 侧回落 QtWebEngine)。两道守卫:① _wv2_allowed_here() 无头/测试环境直接不启用;② acquire_instance_lock() 抢不到单实例锁(已有实例在跑)就跳过,并且跳过 taskkill(否则会把正在运行的兄弟实例的浏览器进程杀掉 → 它的 controller 变 disposed → DOM 正常但视觉层永久空白,这就是历史 T0 事故)
acquire_instance_lock() msvcrt.lockingdata/app_instance.lockHAOCODE_INSTANCE_LOCK_FILE 可覆盖路径,供测试隔离);返回 True=唯一实例 / False=已有实例 / None=平台不支持
Wv2Session 一个 WebView2 实例:创建 controller、找子窗口 hwndfind_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)。AgentMessagecontent/reasoning/tool_calls/stop_reason/error_message/usage/timestamp
agent.py 204 Agentsubscribe/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 AgentRunnerrun 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
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 失败轮次也入库(对照 pimessage_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 两种 workersessions.mode 锁定已发过消息的会话模式
右侧面板接线 bash_panel.set_session(...) + 四个 tool 事件转发(仅当前会话)
关闭清理 closeEvent:中断所有 worker、持久化进行中的回复、清理后台任务

3.2 ui/views/bash_panel.py (944 行) —— 右侧任务面板

名字 说明
BashPanel(QWidget) 外壳:宽 260 / 收起 52panelWidth 属性 + 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/MAXFOLD_MS=200FOLD_STEP=16LAYER_LIMIT=30LIVE_BUF_CAP=200KB_SPLIT_ORIENTATIONVertical,改 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
wv2_view.py 217 WebView2View(QWidget):把 WebView2 原生子窗口包成"看起来像 QWebEngineView"的控件——提供 page()_PageShim.runJavaScript)、setUrlgrabattach_bridgesync_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)。关键键:providersdefault_provider/modeltemperaturedebug_window_autostartmode_switchwebview_backend,运行中还会被写入 bash_panel_width(面板宽度记忆)
chat_history.db 聊天主库(交接版不含,首次运行自动创建空库 + 初始对话)
attachments/ 附件目录(图片/PDF交接版不含;运行时按 <项目根>/data/attachments/ 存放)

7. vendor/webview2/

WebView2 的 .NET SDK(随仓库提供,不走 pip): net462_Microsoft.Web.WebView2.Core.dllclr.AddReference 加载)、webview2loader_x64.dllloader)、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.pysmoke_midswitch.pysmoke_persist.pysmoke_probe.pysmoke_repro_real.pysmoke_timeline.pysmoke_manual.pydiag_live_agent.pydiag_live_onscreen.pydiag_live_text.pyverify_onscreen.pyverify_math_render.pyinject_math_demo.pydebug_inject.pytune_mode_popup.pytune_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' 标记行

⑦ 下一次提问回到 ②,此时模型能看到上一轮的正文、工具调用与结果(失败轮次亦然)