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 存储层
表结构
- 会话是链表树:
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 |
_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 |
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 |
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 写测试的两条铁律
- 不得污染真实库:测试开始时把
core.db_manager._DEFAULT_DB 指向临时文件(必须在 import MainWindow 之前)
- 不得污染真实配置:把
HAOCODE_CONFIG_FILE 指向临时文件(会写 config.json 的功能)
交接版已清除 81 个开发期临时物(_tmp* 脚本 / _tmp_resize_vis/ 截图 / _tmp_*.log)。
9. 一次提问的完整生命周期(把上面所有文件串起来)