mirror of
https://github.com/chaitin/MonkeyCode.git
synced 2026-08-31 01:01:41 +08:00
feat(desktop): 会话回放物化与分页,打开成本与历史长度解耦
打开多轮本地会话要卡好几秒。根因是 journal「一 token 一帧」:实测一份 3 轮
真实会话 8965 帧 / 1.85MB,98.5% 是最终只合并成个位数气泡的流式碎片。这份
数据要走完整读 + 逐行 serde(181ms)→ 序列化(112ms)→ Tauri emit 把载荷内联进
JS 源码 eval(513ms,webview 主线程全冻)→ 单批归约 → 无虚拟化全量挂载;
`session_open` 还串行等着引擎 resume(tiktoken 全量重算,1.4MB 历史 236ms)。
- 折叠物化:相邻同类流式帧合并回一帧(usage/plan 每轮留最后一条),按「一行
一轮」落 replay.jsonl,轮末(task-ended)由 journal 写线程写入。4 份真实
journal 实测 8965→89 帧 / 1.85MB→78KB(2%~14%)。
- 尾部窗口:打开只读最近 20 轮,更早的经 session_history 按 cursor 往前翻
(形状对齐云端 mc_task_rounds)。历史改走 session_open 返回值——返回值天生
有序,不必依赖「监听先于命令」;实时流仍走 frames:{sid}。
- 打开顺序反转:引擎 resume 转后台,上行命令经 ensure_engine_ready 等待而非
报错(恢复期间敲下的消息不丢)。
- 提问大纲:正文左缘点列,悬停展开浮窗,点条目跳转;跳转复用既有滚动锚点
恢复(不是 scrollIntoView——图片/字体加载会把它顶漂),跳到未加载的早期
提问先按轮偏移补历史。
- 大字段护栏:物化那一刻截断超 4KB 的工具字段,留头部 + _meta.mcSrc,全文
按 seq 从 events.jsonl 回读(不建 blobs/,省掉重复存储与孤儿回收)。
取舍记录:物化的是**折叠后的帧**而不是渲染项——物化 LogItem 要在 Rust 里
重写一份 500 行的 reduce.ts,两份归约逻辑此后必须永远同步,且 LogItem 是 UI
内部形状,写进磁盘等于给它套上向后兼容义务。护栏刻意留在 Turn::guard 而不是
折叠里:两者混在一起时「折叠是等价变换」只是因为素材字段小才碰巧成立,
真实 journal 一测即破。
验证:cargo test 122 + e2e(真实 ohmyagent)6 + UI 223 + typecheck/build +
命令与打包契约脚本;4 份真实 journal 的折叠等价性;变异测试确认 Rust/TS
两侧守卫都会红。未做:GUI 手动验收(本机无显示器)与 Windows/macOS 实机。
This commit is contained in:
+21
-1
@@ -58,7 +58,14 @@ tool_call_update(含 progress 子代理 feed 与 failed 终态)/plan/usage_updat
|
||||
llm_call_retry/compact_status/model_update/permission_mode_update`。
|
||||
|
||||
改词汇的顺序:frame.rs 与 types.ts 同一 PR 内同步,
|
||||
reduce.test.ts 补对应归约断言。云端管道帧(ping/cursor/call-response)
|
||||
reduce.test.ts 补对应归约断言。
|
||||
|
||||
**折叠是等价变换**(driver/fold.rs):journal 是「一 token 一帧」,回放前把
|
||||
相邻同类流式碎片合并回一帧(usage/plan 每轮只留最后一条,其余原样)。
|
||||
规则只增不改词汇。等价性由两侧守:Rust 钉住"折叠输出 == fixtures/replay/
|
||||
folded.jsonl",TS 钉住 `reduceBatch(raw) ≡ reduceBatch(folded)`
|
||||
(ui/src/foldEquivalence.test.ts)。素材换了跑
|
||||
`cargo test regenerate_fold_fixture -- --ignored` 重生成。云端管道帧(ping/cursor/call-response)
|
||||
是传输层词汇,不属对话流(遗留:归属待正式定义,见审计清单)。
|
||||
|
||||
## 契约 2:能力模型(Caps)
|
||||
@@ -81,6 +88,12 @@ reduce.test.ts 补对应归约断言。云端管道帧(ping/cursor/call-response
|
||||
`browser-mcp-reloaded`(配对后引擎已带新工具集,UI 整页刷新)、
|
||||
`browser-mcp-refresh-timeout`(等任务空闲超时放弃,UI 提示手动重启;
|
||||
见 main.rs `BROWSER_MCP_REFRESH_DEADLINE`)。UI 侧清单同在 ui/src/ipc.ts。
|
||||
- `session_open` 返回**尾部回放窗口** `{frames, cursor, has_more}`——历史走
|
||||
返回值不走事件(返回值天生有序);更早的按 cursor 走 `session_history`
|
||||
(形状对齐云端 `mc_task_rounds`),`session_outline` 给全量提问目录,
|
||||
`session_frame` 按 seq 回读被截断的工具大字段原文。
|
||||
引擎 resume 不再挡在打开路径上(后台做,conn-status 通知);上行命令经
|
||||
`ensure_engine_ready` 等待而非报错。
|
||||
- **监听先于命令**:壳会在命令处理中同步 emit(回放、管道首帧),
|
||||
Tauri 事件不排队,监听未注册即丢。UI 侧必须 `await listenAsync(...)`
|
||||
完成后再 invoke;需要壳生成 id 的场景改为 UI 生成 id 先注册。
|
||||
@@ -108,6 +121,7 @@ URL/Bearer 进程级新发;首次配对/重置配对在所有前台/后台任务
|
||||
|---|---|
|
||||
| 引擎模型上下文 | app_config_dir/ohmyagent/sessions/<engine_id>/messages.jsonl |
|
||||
| 会话索引/标题/归档/**帧日志**/engine_id 别名 | 壳 sidecar:app_config_dir/ohmy-sessions/<sid>/ |
|
||||
| 回放物化(一行一轮的折叠帧 + events 偏移) | 同上 replay.jsonl(打开只读尾部窗口,见 fold.rs) |
|
||||
| 子代理子会话(有流式事件时物化,仅回放) | 同上(sidecar 带 parent);显式后台纯文本任务可能仅有完成通知 |
|
||||
| 附件 | <workdir>/.monkeycode/uploads(会话工作区内,模型经相对路径可读;旧目录约定的附件按消息内路径回读) |
|
||||
| 百智云/云端凭证 | app_config_dir/*-cookies.json(双罐,互不牵连) |
|
||||
@@ -121,6 +135,10 @@ app_config_dir 走,首启自动迁移旧接管目录的 sessions。
|
||||
引擎 id 与壳 sid 解耦:壳 sid 是目录/UI 通道的稳定标识;engine_id 是
|
||||
可替换属性(空会话无法 resume 时 destroy+全新 create 换绑),出站 RPC
|
||||
映射、入站 shell_sid_of 反查、sidecar 持久化。
|
||||
`events.jsonl` 从"审计"升格为**大字段全文的权威回读源**:物化时超 4KB 的
|
||||
工具字段只留头部 + `_meta.mcSrc`,展开卡片经 `session_frame` 按 seq 取原文
|
||||
(所以不建 blobs/,也所以保留策略不能随手砍——砍了就只剩截断头部)。
|
||||
|
||||
sidecar 更新是串行 read-modify-write,并与配置共用跨平台原子替换;
|
||||
Windows 不得用不能覆盖既有目标的裸 `std::fs::rename`。
|
||||
|
||||
@@ -133,6 +151,8 @@ Windows 不得用不能覆盖既有目标的裸 `std::fs::rename`。
|
||||
- `waiting_ask` 是运行时叠加位(有待答复的审批/提问),不落盘。
|
||||
- 轮次帧序(驱动本地先行,不依赖引擎事件时序):
|
||||
`user-input → task-started → …engine 事件… → [task-error] → task-ended`。
|
||||
- 轮末(`task-ended`,含出错与本地和解两条路径)是**回放物化点**:该轮折叠成
|
||||
replay.jsonl 的一行,并记下此刻 events.jsonl 的长度供下次打开续读。
|
||||
- **和解原则:引擎应答是确认,不是前提。** 引擎停止/崩溃/取消无应答时,
|
||||
驱动本地补收尾(未闭合工具 failed 帧 → task-error → task-ended,
|
||||
状态落 interrupted,挂起审批/提问一并失效);引擎迟到的 turn/stopped
|
||||
|
||||
@@ -26,6 +26,9 @@ fn main() {
|
||||
"session_patch",
|
||||
"models_list",
|
||||
"session_open",
|
||||
"session_history",
|
||||
"session_outline",
|
||||
"session_frame",
|
||||
"session_close",
|
||||
"session_send",
|
||||
"session_call",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{"data":{"content":"5biu5oiR55yL5LiL6L+Z5LiqIHBhbmlj"},"seq":1,"timestamp":1784088476037,"type":"user-input"}
|
||||
{"seq":2,"timestamp":1784088476074,"type":"task-started"}
|
||||
{"data":{"update":{"content":{"text":"我先看一下栈顶那一帧继续想边界条件","type":"text"},"sessionUpdate":"agent_thought_chunk"}},"kind":"acp_event","seq":3,"timestamp":1784088476111,"type":"task-running"}
|
||||
{"data":{"update":{"sessionUpdate":"usage_update","size":200000,"used":3400}},"kind":"acp_event","seq":39,"timestamp":1784088477443,"type":"task-running"}
|
||||
{"data":{"update":{"entries":[{"content":"定位 panic","status":"completed"}],"sessionUpdate":"plan"}},"kind":"acp_event","seq":40,"timestamp":1784088477480,"type":"task-running"}
|
||||
{"data":{"update":{"kind":"read","rawInput":{"path":"src/main.go"},"sessionUpdate":"tool_call","title":"Read","toolCallId":"t1"}},"kind":"acp_event","seq":22,"timestamp":1784088476814,"type":"task-running"}
|
||||
{"data":{"update":{"content":{"text":"读到了越界的索引","type":"text"},"sessionUpdate":"agent_message_chunk"}},"kind":"acp_event","seq":23,"timestamp":1784088476851,"type":"task-running"}
|
||||
{"data":{"update":{"rawOutput":{"output":"line 120: items[idx]"},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"t1"}},"kind":"acp_event","seq":31,"timestamp":1784088477147,"type":"task-running"}
|
||||
{"data":{"update":{"content":{"text":"所以是切片越界","type":"text"},"sessionUpdate":"agent_message_chunk"}},"kind":"acp_event","seq":32,"timestamp":1784088477184,"type":"task-running"}
|
||||
{"seq":41,"timestamp":1784088477517,"type":"task-ended"}
|
||||
{"data":{"content":"6aG65L6/5oqK5rWL6K+V6KGl5LiA5LiLClvlm77niYddIC5tb25rZXljb2RlL3VwbG9hZHMvYS5wbmc="},"seq":42,"timestamp":1784088477554,"type":"user-input"}
|
||||
{"seq":43,"timestamp":1784088477591,"type":"task-started"}
|
||||
{"data":{"update":{"content":{"text":"先写用例","type":"text"},"sessionUpdate":"agent_thought_chunk"}},"kind":"acp_event","seq":44,"timestamp":1784088477628,"type":"task-running"}
|
||||
{"data":{"update":{"kind":"execute","rawInput":{"command":"go test ./..."},"sessionUpdate":"tool_call","title":"Bash","toolCallId":"t2"}},"kind":"acp_event","seq":48,"timestamp":1784088477776,"type":"task-running"}
|
||||
{"data":{"id":"p1","title":"运行 go test","tool":"Bash","tool_call_id":"t2"},"seq":49,"timestamp":1784088477813,"type":"permission-req"}
|
||||
{"data":{"id":"p1","outcome":"approved"},"seq":50,"timestamp":1784088477850,"type":"permission-resolved"}
|
||||
{"data":{"update":{"rawOutput":{"output":"ok 1.2s"},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"t2"}},"kind":"acp_event","seq":51,"timestamp":1784088477887,"type":"task-running"}
|
||||
{"data":{"update":{"content":{"text":"测试通过","type":"text"},"sessionUpdate":"agent_message_chunk"}},"kind":"acp_event","seq":52,"timestamp":1784088477924,"type":"task-running"}
|
||||
{"data":{"update":{"content":{"data":"iVBORw0KGgo=","type":"image"},"sessionUpdate":"agent_message_chunk"}},"kind":"acp_event","seq":53,"timestamp":1784088477961,"type":"task-running"}
|
||||
{"data":{"update":{"content":{"text":"截图在上面","type":"text"},"sessionUpdate":"agent_message_chunk"}},"kind":"acp_event","seq":54,"timestamp":1784088477998,"type":"task-running"}
|
||||
{"data":{"update":{"model":"claude-opus-5","sessionUpdate":"model_update"}},"kind":"acp_event","seq":55,"timestamp":1784088478035,"type":"task-running"}
|
||||
{"data":{"update":{"content":{"text":"换了个模型继续","type":"text"},"sessionUpdate":"agent_message_chunk"}},"kind":"acp_event","seq":56,"timestamp":1784088478072,"type":"task-running"}
|
||||
{"seq":57,"timestamp":1784088478109,"type":"task-ended"}
|
||||
{"data":{"content":"5YaN6LeR5LiA6YGNIENJ"},"seq":58,"timestamp":1784088478146,"type":"user-input"}
|
||||
{"seq":59,"timestamp":1784088478183,"type":"task-started"}
|
||||
{"data":{"update":{"content":{"text":"准备跑","type":"text"},"sessionUpdate":"agent_thought_chunk"}},"kind":"acp_event","seq":60,"timestamp":1784088478220,"type":"task-running"}
|
||||
{"data":{"toolCall":{"rawInput":{"questions":[{"options":[{"label":"全量"},{"label":"只跑单测"}],"question":"跑哪个流水线?"}]},"toolCallId":"q1"}},"kind":"acp_ask_user_question","seq":63,"timestamp":1784088478331,"type":"task-running"}
|
||||
{"data":{"answers_json":"{\"跑哪个流水线?\": \"全量\"}","request_id":"q1"},"seq":64,"timestamp":1784088478368,"type":"reply-question"}
|
||||
{"data":{"update":{"content":{"text":"好的,跑全量","type":"text"},"sessionUpdate":"agent_message_chunk"}},"kind":"acp_event","seq":65,"timestamp":1784088478405,"type":"task-running"}
|
||||
@@ -0,0 +1,65 @@
|
||||
{"type": "user-input", "data": "eyJjb250ZW50IjogIjViaXU1b2lSNTV5TDVMaUw2TCtaNUxpcUlIQmhibWxqIn0=", "seq": 1, "timestamp": 1784088476037}
|
||||
{"type": "task-started", "seq": 2, "timestamp": 1784088476074}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLmiJEifX19", "seq": 3, "timestamp": 1784088476111}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLlhYgifX19", "seq": 4, "timestamp": 1784088476148}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLnnIsifX19", "seq": 5, "timestamp": 1784088476185}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLkuIAifX19", "seq": 6, "timestamp": 1784088476222}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLkuIsifX19", "seq": 7, "timestamp": 1784088476259}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLmoIgifX19", "seq": 8, "timestamp": 1784088476296}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLpobYifX19", "seq": 9, "timestamp": 1784088476333}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLpgqMifX19", "seq": 10, "timestamp": 1784088476370}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLkuIAifX19", "seq": 11, "timestamp": 1784088476407}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLluKcifX19", "seq": 12, "timestamp": 1784088476444}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAidXNhZ2VfdXBkYXRlIiwgInVzZWQiOiAxMjAwLCAic2l6ZSI6IDIwMDAwMH19", "seq": 13, "timestamp": 1784088476481}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLnu6cifX19", "seq": 14, "timestamp": 1784088476518}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLnu60ifX19", "seq": 15, "timestamp": 1784088476555}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLmg7MifX19", "seq": 16, "timestamp": 1784088476592}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLovrkifX19", "seq": 17, "timestamp": 1784088476629}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLnlYwifX19", "seq": 18, "timestamp": 1784088476666}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLmnaEifX19", "seq": 19, "timestamp": 1784088476703}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfdGhvdWdodF9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLku7YifX19", "seq": 20, "timestamp": 1784088476740}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAicGxhbiIsICJlbnRyaWVzIjogW3siY29udGVudCI6ICLlrprkvY0gcGFuaWMiLCAic3RhdHVzIjogImluX3Byb2dyZXNzIn1dfX0=", "seq": 21, "timestamp": 1784088476777}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAidG9vbF9jYWxsIiwgInRvb2xDYWxsSWQiOiAidDEiLCAidGl0bGUiOiAiUmVhZCIsICJraW5kIjogInJlYWQiLCAicmF3SW5wdXQiOiB7InBhdGgiOiAic3JjL21haW4uZ28ifX19", "seq": 22, "timestamp": 1784088476814}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLor7sifX19", "seq": 23, "timestamp": 1784088476851}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLliLAifX19", "seq": 24, "timestamp": 1784088476888}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLkuoYifX19", "seq": 25, "timestamp": 1784088476925}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLotooifX19", "seq": 26, "timestamp": 1784088476962}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLnlYwifX19", "seq": 27, "timestamp": 1784088476999}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLnmoQifX19", "seq": 28, "timestamp": 1784088477036}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLntKIifX19", "seq": 29, "timestamp": 1784088477073}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLlvJUifX19", "seq": 30, "timestamp": 1784088477110}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAidG9vbF9jYWxsX3VwZGF0ZSIsICJ0b29sQ2FsbElkIjogInQxIiwgInN0YXR1cyI6ICJjb21wbGV0ZWQiLCAicmF3T3V0cHV0IjogeyJvdXRwdXQiOiAibGluZSAxMjA6IGl0ZW1zW2lkeF0ifX19", "seq": 31, "timestamp": 1784088477147}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLmiYAifX19", "seq": 32, "timestamp": 1784088477184}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLku6UifX19", "seq": 33, "timestamp": 1784088477221}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLmmK8ifX19", "seq": 34, "timestamp": 1784088477258}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLliIcifX19", "seq": 35, "timestamp": 1784088477295}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLniYcifX19", "seq": 36, "timestamp": 1784088477332}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLotooifX19", "seq": 37, "timestamp": 1784088477369}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAiYWdlbnRfbWVzc2FnZV9jaHVuayIsICJjb250ZW50IjogeyJ0eXBlIjogInRleHQiLCAidGV4dCI6ICLnlYwifX19", "seq": 38, "timestamp": 1784088477406}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAidXNhZ2VfdXBkYXRlIiwgInVzZWQiOiAzNDAwLCAic2l6ZSI6IDIwMDAwMH19", "seq": 39, "timestamp": 1784088477443}
|
||||
{"type": "task-running", "kind": "acp_event", "data": "eyJ1cGRhdGUiOiB7InNlc3Npb25VcGRhdGUiOiAicGxhbiIsICJlbnRyaWVzIjogW3siY29udGVudCI6ICLlrprkvY0gcGFuaWMiLCAic3RhdHVzIjogImNvbXBsZXRlZCJ9XX19", "seq": 40, "timestamp": 1784088477480}
|
||||
{"type": "task-ended", "seq": 41, "timestamp": 1784088477517}
|
||||
{"type": "user-input", "data": {"content": "6aG65L6/5oqK5rWL6K+V6KGl5LiA5LiLClvlm77niYddIC5tb25rZXljb2RlL3VwbG9hZHMvYS5wbmc="}, "seq": 42, "timestamp": 1784088477554}
|
||||
{"type": "task-started", "seq": 43, "timestamp": 1784088477591}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "先"}}}, "seq": 44, "timestamp": 1784088477628}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "写"}}}, "seq": 45, "timestamp": 1784088477665}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "用"}}}, "seq": 46, "timestamp": 1784088477702}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "例"}}}, "seq": 47, "timestamp": 1784088477739}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "tool_call", "toolCallId": "t2", "title": "Bash", "kind": "execute", "rawInput": {"command": "go test ./..."}}}, "seq": 48, "timestamp": 1784088477776}
|
||||
{"type": "permission-req", "data": {"id": "p1", "tool": "Bash", "title": "运行 go test", "tool_call_id": "t2"}, "seq": 49, "timestamp": 1784088477813}
|
||||
{"type": "permission-resolved", "data": {"id": "p1", "outcome": "approved"}, "seq": 50, "timestamp": 1784088477850}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "tool_call_update", "toolCallId": "t2", "status": "completed", "rawOutput": {"output": "ok 1.2s"}}}, "seq": 51, "timestamp": 1784088477887}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "测试通过"}}}, "seq": 52, "timestamp": 1784088477924}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "image", "data": "iVBORw0KGgo="}}}, "seq": 53, "timestamp": 1784088477961}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "截图在上面"}}}, "seq": 54, "timestamp": 1784088477998}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "model_update", "model": "claude-opus-5"}}, "seq": 55, "timestamp": 1784088478035}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "换了个模型继续"}}}, "seq": 56, "timestamp": 1784088478072}
|
||||
{"type": "task-ended", "seq": 57, "timestamp": 1784088478109}
|
||||
{"type": "user-input", "data": {"content": "5YaN6LeR5LiA6YGNIENJ"}, "seq": 58, "timestamp": 1784088478146}
|
||||
{"type": "task-started", "seq": 59, "timestamp": 1784088478183}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "准"}}}, "seq": 60, "timestamp": 1784088478220}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "备"}}}, "seq": 61, "timestamp": 1784088478257}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "跑"}}}, "seq": 62, "timestamp": 1784088478294}
|
||||
{"type": "task-running", "kind": "acp_ask_user_question", "data": {"toolCall": {"toolCallId": "q1", "rawInput": {"questions": [{"question": "跑哪个流水线?", "options": [{"label": "全量"}, {"label": "只跑单测"}]}]}}}, "seq": 63, "timestamp": 1784088478331}
|
||||
{"type": "reply-question", "data": {"request_id": "q1", "answers_json": "{\"跑哪个流水线?\": \"全量\"}"}, "seq": 64, "timestamp": 1784088478368}
|
||||
{"type": "task-running", "kind": "acp_event", "data": {"update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "好的,跑全量"}}}, "seq": 65, "timestamp": 1784088478405}
|
||||
@@ -0,0 +1,493 @@
|
||||
// 回放折叠 + 「一轮一行」的 replay.jsonl 存取。
|
||||
//
|
||||
// 为什么存在:events.jsonl 是「一 token 一帧」的原始流,打开会话要把它整份
|
||||
// 搬到 UI——实测一份 3 轮真实会话 8965 帧 / 1.9MB,其中 98.5% 是最终只会被
|
||||
// 合并成个位数气泡的流式碎片。折叠把同类连续碎片并回一帧,再按「一轮一行」
|
||||
// 落到 replay.jsonl:打开只读尾部若干轮,成本与历史长度解耦。
|
||||
//
|
||||
// **折叠是等价变换**:UI 侧 reduceBatch(原始) 与 reduceBatch(折叠) 结果逐字段
|
||||
// 相同。守卫是 fixtures/replay/ 的合成素材(覆盖旧 base64、被打断的流式段、
|
||||
// 覆盖语义帧、审批、提问卡、图片),Rust 钉输出、TS 钉语义;落地时另在 4 份
|
||||
// 真实 journal 上验过(8965→89 帧 / 1.85MB→78KB,归约结果逐字段相同)。规则:
|
||||
// - `agent_message_chunk`/`agent_thought_chunk`:**严格相邻**的同类合并,text
|
||||
// 拼接,保留首帧 timestamp/seq(reduce 的 appendStream 只在新建项时取
|
||||
// timestamp,合并时忽略)。不相邻则不合并——保守规则,不必复刻 reduce 里
|
||||
// streamKind + last-item 的全部细节,也就不会因为那些细节演化而失配。
|
||||
// - `usage_update`/`plan`:整轮只留一帧,首现位置就地更新为最新值。两者在
|
||||
// reduce 里都是整体覆盖、不碰 items 也不碰 streamKind,所以既不打断上面的
|
||||
// 相邻性判定,轮末状态也与原始一致。
|
||||
// - 其余帧原样保留,并打断流式相邻性。
|
||||
//
|
||||
// 折叠之外还有一道**大字段护栏**(Turn::guard),只在物化那一刻施加:超限的
|
||||
// 工具字段截到头部 + `_meta.mcSrc`,全文按 seq 从 events.jsonl 回读。它是有意
|
||||
// 的有损变换,**不在等价性契约内**——所以它长在 Turn::guard 而不是折叠里。
|
||||
//
|
||||
// 落盘格式(replay.jsonl,一行一轮):
|
||||
// {"from":首帧 seq,"to":末帧 seq,"src_end":events.jsonl 字节偏移,"frames":[…]}
|
||||
// `src_end` 是这一轮写完时 events.jsonl 的长度,打开时据此只读「尚未物化的
|
||||
// 尾巴」。崩溃只会让最后一行不完整 → 解析失败被跳过 → 那一轮退回原始帧回放,
|
||||
// 不会丢也不会重。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read as _, Seek as _, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 打开时回放的尾部窗口:最多这么多轮
|
||||
pub(super) const TAIL_TURNS: usize = 20;
|
||||
/// 且最多这么多帧(长轮次一轮就顶满时,至少保证完整的一轮)
|
||||
pub(super) const TAIL_FRAMES: usize = 3000;
|
||||
|
||||
/// 工具字段超过这个字节数就在物化时截断。折叠拿走的是数量级(实测 1.9MB →
|
||||
/// 77KB),折叠之后 tool_call/tool_call_update 反而成了大头(5 份真实会话里
|
||||
/// 占折叠后体积的 13%~83%)。但它的价值**不是省中位数**(按 4KB 阈值也只
|
||||
/// 再省 33%),而是**削峰**:一次 Read 读 2MB 文件、一次大 diff、一张 base64
|
||||
/// 图,单帧就能几 MB,折叠对这类完全无效。
|
||||
const FIELD_LIMIT: usize = 4096;
|
||||
/// 截断后行内保留的头部(足够卡片折叠态的首行摘要与多数展开场景)
|
||||
const FIELD_HEAD: usize = 1024;
|
||||
|
||||
// ==================== 帧检视 ====================
|
||||
|
||||
/// 帧 data 归一为内联对象。旧 journal 的 data 是 base64(JSON) 字符串
|
||||
/// (frame.rs 模块头有历史说明),折叠输出一律内联对象——UI 的 frameData
|
||||
/// 两种都吃,老会话物化之后就此不再每帧 atob。
|
||||
fn inline_data(f: &Value) -> Option<Value> {
|
||||
match f.get("data") {
|
||||
Some(Value::String(s)) => base64::engine::general_purpose::STANDARD
|
||||
.decode(s)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok()),
|
||||
Some(Value::Null) | None => None,
|
||||
Some(v) => Some(v.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// data 已是内联对象则原样返回,否则重写成内联对象形态。
|
||||
fn normalized(f: &Value) -> Value {
|
||||
match f.get("data") {
|
||||
Some(Value::String(_)) => {
|
||||
let mut nf = f.clone();
|
||||
match inline_data(f) {
|
||||
Some(d) => nf["data"] = d,
|
||||
// 解不开的 data 保持原样:宁可让 UI 按坏载荷处理,也不丢帧
|
||||
None => return f.clone(),
|
||||
}
|
||||
nf
|
||||
}
|
||||
_ => f.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn session_update(data: &Value) -> Option<&str> {
|
||||
data.get("update")?.get("sessionUpdate")?.as_str()
|
||||
}
|
||||
|
||||
/// 可合并的流式帧 → 其 sessionUpdate;content 不是 `{type:"text"}` 的
|
||||
/// 一律不合并(图片等结构化载荷拼不了)
|
||||
fn stream_kind(data: &Value) -> Option<&str> {
|
||||
let u = data.get("update")?;
|
||||
let su = u.get("sessionUpdate")?.as_str()?;
|
||||
if su != "agent_message_chunk" && su != "agent_thought_chunk" {
|
||||
return None;
|
||||
}
|
||||
let c = u.get("content")?;
|
||||
(c.get("type").and_then(|v| v.as_str()) == Some("text") && c.get("text").is_some())
|
||||
.then_some(su)
|
||||
}
|
||||
|
||||
fn chunk_text(f: &Value) -> Option<&str> {
|
||||
f.get("data")?.get("update")?.get("content")?.get("text")?.as_str()
|
||||
}
|
||||
|
||||
fn is_overwrite(su: &str) -> bool {
|
||||
su == "usage_update" || su == "plan"
|
||||
}
|
||||
|
||||
fn frame_seq(f: &Value) -> u64 {
|
||||
f.get("seq").and_then(|v| v.as_u64()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(super) fn is_turn_end(f: &Value) -> bool {
|
||||
f.get("type").and_then(|v| v.as_str()) == Some("task-ended")
|
||||
}
|
||||
|
||||
// ==================== 大字段护栏 ====================
|
||||
|
||||
/// 按字符边界把字符串截到 head 字节以内。
|
||||
fn cut(s: &str, head: usize) -> String {
|
||||
let mut end = head.min(s.len());
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}…", &s[..end])
|
||||
}
|
||||
|
||||
/// 递归截断过长的字符串叶子,**保持结构不变**(UI 的 toolResultText /
|
||||
/// presentToolCall 依赖字段形状,换成占位对象会渲染出乱七八糟的东西)。
|
||||
/// 返回是否截断过。
|
||||
fn truncate_strings(v: &mut Value, head: usize) -> bool {
|
||||
match v {
|
||||
Value::String(s) if s.len() > head => {
|
||||
*s = cut(s, head);
|
||||
true
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().fold(false, |acc, x| truncate_strings(x, head) || acc),
|
||||
Value::Object(map) => {
|
||||
map.iter_mut().fold(false, |acc, (_, x)| truncate_strings(x, head) || acc)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 物化前给工具帧的大字段做护栏:超限即就地截断,并在 `_meta.mcSrc` 留下
|
||||
/// 回读凭据(seq)。完整正文本来就躺在 events.jsonl 里(审计源,不删),
|
||||
/// 所以**不建 blobs/**——省掉一份重复存储、哈希与孤儿回收。
|
||||
/// `_meta` 是既有的透传扩展点,reduce 原样带到 LogItem 上,归约层零改动。
|
||||
fn guard_big_fields(nf: &mut Value) -> bool {
|
||||
let seq = frame_seq(nf);
|
||||
let Some(update) = nf.pointer_mut("/data/update") else { return false };
|
||||
let su = update.get("sessionUpdate").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if su != "tool_call" && su != "tool_call_update" {
|
||||
return false;
|
||||
}
|
||||
let mut cut_any = false;
|
||||
for key in ["rawInput", "rawOutput", "content"] {
|
||||
let Some(field) = update.get_mut(key) else { continue };
|
||||
// 只丈量一次:小字段(绝大多数)不进递归
|
||||
if serde_json::to_string(field).map(|s| s.len()).unwrap_or(0) <= FIELD_LIMIT {
|
||||
continue;
|
||||
}
|
||||
cut_any |= truncate_strings(field, FIELD_HEAD);
|
||||
}
|
||||
if cut_any {
|
||||
update["_meta"]["mcSrc"] = json!({ "seq": seq });
|
||||
}
|
||||
cut_any
|
||||
}
|
||||
|
||||
// ==================== 增量折叠器 ====================
|
||||
|
||||
/// 一轮的增量折叠。挂在 SessionState 上,push_frame 逐帧喂;轮末
|
||||
/// (task-ended)取走整轮结果投给 journal 写线程。
|
||||
///
|
||||
/// 增量而非「攒原始帧再折叠」:内存里只留折叠后的百来帧,长轮次
|
||||
/// (一次 10 万 token 输出)不会把原始碎片堆在内存里。
|
||||
#[derive(Default)]
|
||||
pub(super) struct TurnFold {
|
||||
out: Vec<Value>,
|
||||
/// out 中可继续拼接的流式帧 (下标, sessionUpdate)
|
||||
tail: Option<(usize, String)>,
|
||||
/// 覆盖语义帧的首现位置(sessionUpdate → out 下标)
|
||||
over: HashMap<String, usize>,
|
||||
from: u64,
|
||||
to: u64,
|
||||
}
|
||||
|
||||
impl TurnFold {
|
||||
pub(super) fn push(&mut self, f: &Value) {
|
||||
let seq = frame_seq(f);
|
||||
if self.out.is_empty() {
|
||||
self.from = seq;
|
||||
}
|
||||
self.to = self.to.max(seq);
|
||||
|
||||
let nf = normalized(f);
|
||||
let Some(data) = nf.get("data") else {
|
||||
self.push_opaque(nf);
|
||||
return;
|
||||
};
|
||||
let Some(su) = session_update(data).map(str::to_string) else {
|
||||
self.push_opaque(nf);
|
||||
return;
|
||||
};
|
||||
if is_overwrite(&su) {
|
||||
// 就地更新首现位置,tail 不动:usage/plan 在 reduce 里既不碰 items
|
||||
// 也不碰 streamKind,对流式相邻性是透明的
|
||||
match self.over.get(&su) {
|
||||
Some(&idx) => self.out[idx] = nf,
|
||||
None => {
|
||||
self.over.insert(su, self.out.len());
|
||||
self.out.push(nf);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if stream_kind(data).is_some() {
|
||||
if let Some((idx, kind)) = &self.tail {
|
||||
if kind == &su {
|
||||
let idx = *idx;
|
||||
if let (Some(prev), Some(add)) =
|
||||
(chunk_text(&self.out[idx]).map(str::to_string), chunk_text(&nf))
|
||||
{
|
||||
self.out[idx]["data"]["update"]["content"]["text"] =
|
||||
json!(prev + add);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.out.push(nf);
|
||||
self.tail = Some((self.out.len() - 1, su));
|
||||
return;
|
||||
}
|
||||
self.push_opaque(nf);
|
||||
}
|
||||
|
||||
fn push_opaque(&mut self, nf: Value) {
|
||||
self.out.push(nf);
|
||||
self.tail = None;
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.out.is_empty()
|
||||
}
|
||||
|
||||
/// 取走整轮并复位
|
||||
pub(super) fn take(&mut self) -> Turn {
|
||||
let taken = std::mem::take(self);
|
||||
Turn { from: taken.from, to: taken.to, frames: taken.out }
|
||||
}
|
||||
}
|
||||
|
||||
/// 一轮的折叠结果
|
||||
pub(super) struct Turn {
|
||||
pub(super) from: u64,
|
||||
pub(super) to: u64,
|
||||
pub(super) frames: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Turn {
|
||||
/// 大字段护栏:**只在物化这一刻**做。折叠必须保持等价(见模块头),
|
||||
/// 截断是有意的有损变换;两者混在一起的话"等价性"就只是碰巧成立——
|
||||
/// 真实 journal 上一测即破(实测差异全落在被截的 rawOutput/result/_meta)。
|
||||
/// 未物化的当前轮因此始终是完整内容。
|
||||
pub(super) fn guard(&mut self) {
|
||||
for f in &mut self.frames {
|
||||
guard_big_fields(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// 序列化为 replay.jsonl 的一行(不含换行)
|
||||
pub(super) fn to_line(&self, src_end: u64) -> String {
|
||||
json!({
|
||||
"from": self.from,
|
||||
"to": self.to,
|
||||
"src_end": src_end,
|
||||
"frames": self.frames,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量折叠(整段原始帧 → 折叠帧),用于未物化尾巴的即时回放与老会话迁移。
|
||||
pub(super) fn fold_frames(frames: &[Value]) -> Vec<Value> {
|
||||
let mut f = TurnFold::default();
|
||||
for x in frames {
|
||||
f.push(x);
|
||||
}
|
||||
f.take().frames
|
||||
}
|
||||
|
||||
// ==================== replay.jsonl 存取 ====================
|
||||
|
||||
/// 一行轮记录的解析结果
|
||||
pub(super) struct TurnLine {
|
||||
/// 该行在文件中的起始偏移(分页 cursor / 大纲跳转锚点)
|
||||
pub(super) offset: u64,
|
||||
pub(super) from: u64,
|
||||
pub(super) to: u64,
|
||||
pub(super) src_end: u64,
|
||||
pub(super) frames: Vec<Value>,
|
||||
}
|
||||
|
||||
fn parse_turn_line(offset: u64, line: &str) -> Option<TurnLine> {
|
||||
let v: Value = serde_json::from_str(line).ok()?;
|
||||
Some(TurnLine {
|
||||
offset,
|
||||
from: v.get("from").and_then(|x| x.as_u64()).unwrap_or(0),
|
||||
to: v.get("to").and_then(|x| x.as_u64()).unwrap_or(0),
|
||||
src_end: v.get("src_end").and_then(|x| x.as_u64()).unwrap_or(0),
|
||||
frames: v.get("frames").and_then(|x| x.as_array()).cloned().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 `before` 字节处往前读最多 max 行完整行,返回 (行起始偏移, 行内容),
|
||||
/// 结果按文件正序。`before` 恒落在行边界(我们自己写的行 + 自己给的 cursor)。
|
||||
///
|
||||
/// 分块反向读而不是整读:打开成本必须与历史长度解耦,否则前面所有努力
|
||||
/// 都被"每次仍要把整个 replay.jsonl 读进内存"吃掉。
|
||||
fn lines_backward(path: &Path, before: u64, max: usize) -> Vec<(u64, String)> {
|
||||
const CHUNK: usize = 64 * 1024;
|
||||
let Ok(mut f) = std::fs::File::open(path) else { return vec![] };
|
||||
let Ok(meta) = f.metadata() else { return vec![] };
|
||||
let end = before.min(meta.len());
|
||||
if end == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let mut start = end;
|
||||
let mut acc: Vec<u8> = Vec::new();
|
||||
while start > 0 && acc.iter().filter(|b| **b == b'\n').count() <= max {
|
||||
let take = CHUNK.min(start as usize);
|
||||
start -= take as u64;
|
||||
let mut chunk = vec![0u8; take];
|
||||
if f.seek(SeekFrom::Start(start)).is_err() || f.read_exact(&mut chunk).is_err() {
|
||||
return vec![];
|
||||
}
|
||||
chunk.extend_from_slice(&acc);
|
||||
acc = chunk;
|
||||
}
|
||||
// start > 0 时首行多半被切断,丢到第一个换行为止
|
||||
let mut base = start;
|
||||
let mut body = &acc[..];
|
||||
if start > 0 {
|
||||
match body.iter().position(|b| *b == b'\n') {
|
||||
Some(p) => {
|
||||
base += p as u64 + 1;
|
||||
body = &body[p + 1..];
|
||||
}
|
||||
None => return vec![],
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut off = base;
|
||||
for part in body.split_inclusive(|b| *b == b'\n') {
|
||||
let has_nl = part.last() == Some(&b'\n');
|
||||
let text = &part[..part.len() - usize::from(has_nl)];
|
||||
if has_nl && !text.is_empty() {
|
||||
if let Ok(s) = std::str::from_utf8(text) {
|
||||
out.push((off, s.to_string()));
|
||||
}
|
||||
}
|
||||
off += part.len() as u64;
|
||||
}
|
||||
if out.len() > max {
|
||||
out.drain(..out.len() - max);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 读尾部窗口:最近 TAIL_TURNS 轮且不超过 TAIL_FRAMES 帧(至少一轮)。
|
||||
/// 返回 (轮列表正序, 是否还有更早的)。
|
||||
pub(super) fn read_tail(path: &Path) -> (Vec<TurnLine>, bool) {
|
||||
read_before(path, u64::MAX, TAIL_TURNS)
|
||||
}
|
||||
|
||||
/// 读 `before` 之前的最多 limit 轮。返回 (轮列表正序, 是否还有更早的)。
|
||||
pub(super) fn read_before(path: &Path, before: u64, limit: usize) -> (Vec<TurnLine>, bool) {
|
||||
let lines = lines_backward(path, before, limit.max(1));
|
||||
let mut turns: Vec<TurnLine> = Vec::new();
|
||||
let mut frames = 0usize;
|
||||
// 从最新往回收,凑够帧数就停——但至少保住一轮,否则长轮次会空窗
|
||||
for (off, line) in lines.iter().rev() {
|
||||
let Some(t) = parse_turn_line(*off, line) else { continue };
|
||||
let n = t.frames.len();
|
||||
if !turns.is_empty() && frames + n > TAIL_FRAMES {
|
||||
break;
|
||||
}
|
||||
frames += n;
|
||||
turns.push(t);
|
||||
}
|
||||
turns.reverse();
|
||||
let earliest = turns.first().map(|t| t.offset).unwrap_or(0);
|
||||
(turns, earliest > 0)
|
||||
}
|
||||
|
||||
/// 按 seq 在 events.jsonl 里回读原始帧(大字段截断后的全文来源)。
|
||||
/// 先用 replay.jsonl 的轮记录把范围收敛到那一轮对应的字节区间,再扫;
|
||||
/// 定位不到(未物化的当前轮/记录缺失)才退化为全文件扫描。
|
||||
pub(super) fn read_frame_by_seq(replay: &Path, events: &Path, seq: u64) -> Option<Value> {
|
||||
let mut start = 0u64;
|
||||
let mut end = u64::MAX;
|
||||
let mut prev_src_end = 0u64;
|
||||
for (offset, line) in scan_lines(replay) {
|
||||
let Some(t) = parse_turn_line(offset, &line) else { continue };
|
||||
if seq >= t.from && seq <= t.to {
|
||||
start = prev_src_end;
|
||||
end = t.src_end;
|
||||
break;
|
||||
}
|
||||
prev_src_end = t.src_end;
|
||||
}
|
||||
// 未物化的尾巴:从最后一轮的 src_end 起找到文件尾
|
||||
if end == u64::MAX {
|
||||
start = prev_src_end;
|
||||
}
|
||||
|
||||
use std::io::{BufRead as _, BufReader, Seek as _, SeekFrom};
|
||||
let mut f = std::fs::File::open(events).ok()?;
|
||||
f.seek(SeekFrom::Start(start)).ok()?;
|
||||
let mut r = BufReader::new(f);
|
||||
let mut pos = start;
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match r.read_line(&mut line) {
|
||||
Ok(0) | Err(_) => return None,
|
||||
Ok(n) => {
|
||||
pos += n as u64;
|
||||
if let Ok(v) = serde_json::from_str::<Value>(line.trim_end()) {
|
||||
if frame_seq(&v) == seq {
|
||||
// 回读结果同样归一成内联对象,UI 拿到即用
|
||||
return Some(normalized(&v));
|
||||
}
|
||||
}
|
||||
if pos >= end {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 顺序扫描全文(带行偏移);大纲用。文件是折叠后的,量级几十 KB~数 MB。
|
||||
pub(super) fn scan_lines(path: &Path) -> Vec<(u64, String)> {
|
||||
use std::io::{BufRead as _, BufReader};
|
||||
let Ok(f) = std::fs::File::open(path) else { return vec![] };
|
||||
let mut r = BufReader::new(f);
|
||||
let mut out = Vec::new();
|
||||
let mut off = 0u64;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match r.read_line(&mut line) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
let trimmed = line.trim_end_matches('\n');
|
||||
if line.ends_with('\n') && !trimmed.is_empty() {
|
||||
out.push((off, trimmed.to_string()));
|
||||
}
|
||||
off += n as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 一行轮记录 → 其中的用户提问条目(大纲)
|
||||
pub(super) fn outline_of_line(offset: u64, line: &str) -> Vec<Value> {
|
||||
let Ok(v) = serde_json::from_str::<Value>(line) else { return vec![] };
|
||||
let Some(frames) = v.get("frames").and_then(|x| x.as_array()) else { return vec![] };
|
||||
frames.iter().filter_map(|f| outline_entry(offset, f)).collect()
|
||||
}
|
||||
|
||||
/// user-input 帧 → 大纲条目(非 user-input 返回 None)。
|
||||
/// text 保持 base64(与帧内一致),截断与附件行剥离交 UI——那套规则
|
||||
/// (ATT_LINE)本来就长在 UI 侧,不复制到壳里。
|
||||
pub(super) fn outline_entry(offset: u64, f: &Value) -> Option<Value> {
|
||||
if f.get("type").and_then(|v| v.as_str()) != Some("user-input") {
|
||||
return None;
|
||||
}
|
||||
let data = inline_data(f)?;
|
||||
let content = data.get("content").and_then(|v| v.as_str())?;
|
||||
Some(json!({
|
||||
"seq": frame_seq(f),
|
||||
"offset": offset,
|
||||
"content": content,
|
||||
"timestamp": f.get("timestamp").cloned().unwrap_or(Value::Null),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fold_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,377 @@
|
||||
// fold.rs 的单测:折叠规则(等价变换的壳侧一半)与 replay.jsonl 的
|
||||
// 反向分页读取。UI 侧还有一条端到端等价性断言(foldEquivalence.test.ts),
|
||||
// 两边合起来才是完整守卫。
|
||||
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn chunk(su: &str, text: &str, seq: u64, ts: u64) -> Value {
|
||||
json!({
|
||||
"type": "task-running",
|
||||
"kind": "acp_event",
|
||||
"data": { "update": { "sessionUpdate": su, "content": { "type": "text", "text": text } } },
|
||||
"timestamp": ts,
|
||||
"seq": seq,
|
||||
})
|
||||
}
|
||||
|
||||
fn acp(update: Value, seq: u64) -> Value {
|
||||
json!({ "type": "task-running", "kind": "acp_event", "data": { "update": update },
|
||||
"timestamp": 1, "seq": seq })
|
||||
}
|
||||
|
||||
fn lifecycle(t: &str, seq: u64) -> Value {
|
||||
json!({ "type": t, "timestamp": 1, "seq": seq })
|
||||
}
|
||||
|
||||
fn text_of(f: &Value) -> String {
|
||||
f["data"]["update"]["content"]["text"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
fn temp_dir(label: &str) -> PathBuf {
|
||||
let mut random = [0u8; 8];
|
||||
getrandom::getrandom(&mut random).unwrap();
|
||||
let suffix = random.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||
let dir = std::env::temp_dir().join(format!("monkeycode-fold-{label}-{suffix}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_adjacent_chunks_of_the_same_kind_keeping_the_first_frames_stamp() {
|
||||
let out = fold_frames(&[
|
||||
chunk("agent_thought_chunk", "用户", 3, 1_000),
|
||||
chunk("agent_thought_chunk", "只", 4, 1_200),
|
||||
chunk("agent_thought_chunk", "问了一句", 5, 1_400),
|
||||
]);
|
||||
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(text_of(&out[0]), "用户只问了一句");
|
||||
// 首帧的 seq/timestamp:reduce 的 appendStream 只在新建项时取 timestamp
|
||||
assert_eq!(out[0]["seq"], json!(3));
|
||||
assert_eq!(out[0]["timestamp"], json!(1_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_merge_across_a_different_kind_or_an_opaque_frame() {
|
||||
let out = fold_frames(&[
|
||||
chunk("agent_thought_chunk", "先想", 1, 1),
|
||||
chunk("agent_message_chunk", "再说", 2, 1),
|
||||
chunk("agent_thought_chunk", "又想", 3, 1),
|
||||
acp(json!({ "sessionUpdate": "tool_call", "toolCallId": "t1", "title": "Read" }), 4),
|
||||
chunk("agent_thought_chunk", "还想", 5, 1),
|
||||
]);
|
||||
|
||||
let kinds: Vec<&str> = out
|
||||
.iter()
|
||||
.map(|f| f["data"]["update"]["sessionUpdate"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
["agent_thought_chunk", "agent_message_chunk", "agent_thought_chunk", "tool_call", "agent_thought_chunk"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_and_plan_collapse_to_one_frame_and_stay_transparent_to_merging() {
|
||||
let out = fold_frames(&[
|
||||
chunk("agent_message_chunk", "前半", 1, 1),
|
||||
acp(json!({ "sessionUpdate": "usage_update", "used": 10, "size": 100 }), 2),
|
||||
chunk("agent_message_chunk", "后半", 3, 1),
|
||||
acp(json!({ "sessionUpdate": "usage_update", "used": 20, "size": 100 }), 4),
|
||||
]);
|
||||
|
||||
// usage 只剩一帧且是最新值;正文没被它打断
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(text_of(&out[0]), "前半后半");
|
||||
assert_eq!(out[1]["data"]["update"]["used"], json!(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_base64_payloads_are_normalised_to_inline_objects() {
|
||||
let payload = json!({ "update": { "sessionUpdate": "agent_message_chunk",
|
||||
"content": { "type": "text", "text": "旧格式" } } });
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(payload.to_string());
|
||||
let legacy = json!({ "type": "task-running", "kind": "acp_event",
|
||||
"data": encoded, "timestamp": 1, "seq": 1 });
|
||||
|
||||
let out = fold_frames(&[legacy, chunk("agent_message_chunk", "接上", 2, 1)]);
|
||||
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(out[0]["data"].is_object(), "折叠输出一律内联对象,老会话物化后不再每帧 atob");
|
||||
assert_eq!(text_of(&out[0]), "旧格式接上");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_and_other_structured_content_are_never_concatenated() {
|
||||
let img = acp(
|
||||
json!({ "sessionUpdate": "agent_message_chunk", "content": { "type": "image", "data": "…" } }),
|
||||
2,
|
||||
);
|
||||
let out = fold_frames(&[chunk("agent_message_chunk", "看图", 1, 1), img]);
|
||||
|
||||
assert_eq!(out.len(), 2, "非文本 content 拼不了,必须原样保留");
|
||||
}
|
||||
|
||||
// ==================== replay.jsonl 读取 ====================
|
||||
|
||||
fn write_turns(dir: &PathBuf, turns: &[(u64, u64, usize)]) -> PathBuf {
|
||||
use std::io::Write as _;
|
||||
let path = dir.join("replay.jsonl");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
for (from, to, n) in turns {
|
||||
let frames: Vec<Value> = (0..*n).map(|i| chunk("agent_message_chunk", "x", from + i as u64, 1)).collect();
|
||||
let turn = Turn { from: *from, to: *to, frames };
|
||||
writeln!(f, "{}", turn.to_line(to * 10)).unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_tail_returns_the_newest_turns_in_order_with_a_paging_cursor() {
|
||||
let dir = temp_dir("tail");
|
||||
let path = write_turns(&dir, &[(1, 9, 2), (10, 19, 2), (20, 29, 2)]);
|
||||
|
||||
let (turns, has_more) = read_tail(&path);
|
||||
|
||||
assert_eq!(turns.len(), 3);
|
||||
assert_eq!(turns[0].to, 9, "正序返回");
|
||||
assert_eq!(turns[2].to, 29);
|
||||
assert!(!has_more, "已经读到文件头");
|
||||
assert_eq!(turns[2].src_end, 290);
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_before_pages_backwards_from_a_cursor() {
|
||||
let dir = temp_dir("page");
|
||||
let path = write_turns(&dir, &[(1, 9, 1), (10, 19, 1), (20, 29, 1)]);
|
||||
let (tail, _) = read_before(&path, u64::MAX, 1);
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].to, 29);
|
||||
|
||||
let (older, has_more) = read_before(&path, tail[0].offset, 1);
|
||||
|
||||
assert_eq!(older.len(), 1);
|
||||
assert_eq!(older[0].to, 19);
|
||||
assert!(has_more, "前面还有第一轮");
|
||||
|
||||
let (first, no_more) = read_before(&path, older[0].offset, 5);
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(first[0].to, 9);
|
||||
assert!(!no_more);
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_tail_stops_on_the_frame_budget_but_always_keeps_one_turn() {
|
||||
let dir = temp_dir("budget");
|
||||
// 单轮就超预算:窗口仍必须给出完整的一轮,否则长轮次会开出空白
|
||||
let path = write_turns(&dir, &[(1, 9, 10), (10, 19, TAIL_FRAMES + 100)]);
|
||||
|
||||
let (turns, has_more) = read_tail(&path);
|
||||
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert_eq!(turns[0].to, 19);
|
||||
assert!(has_more);
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_torn_last_line_degrades_to_the_previous_turn_instead_of_corrupting_the_window() {
|
||||
use std::io::Write as _;
|
||||
let dir = temp_dir("torn");
|
||||
let path = write_turns(&dir, &[(1, 9, 1), (10, 19, 1)]);
|
||||
// 模拟崩溃:半行没有换行结尾
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
write!(f, "{{\"from\":20,\"to\":29,\"frames\":[").unwrap();
|
||||
drop(f);
|
||||
|
||||
let (turns, _) = read_tail(&path);
|
||||
|
||||
assert_eq!(turns.len(), 2, "残行不成行,被跳过");
|
||||
assert_eq!(turns[1].to, 19);
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_entries_come_from_user_input_frames_only() {
|
||||
let ui = json!({
|
||||
"type": "user-input",
|
||||
"data": { "content": base64::engine::general_purpose::STANDARD.encode("帮我看下这个 panic") },
|
||||
"timestamp": 1_784_088_476_056u64,
|
||||
"seq": 7,
|
||||
});
|
||||
|
||||
assert!(outline_entry(0, &lifecycle("task-started", 1)).is_none());
|
||||
let e = outline_entry(512, &ui).unwrap();
|
||||
assert_eq!(e["seq"], json!(7));
|
||||
assert_eq!(e["offset"], json!(512));
|
||||
assert_eq!(e["timestamp"], json!(1_784_088_476_056u64));
|
||||
// content 保持 base64:截断与附件行剥离的规则长在 UI,不复制到壳里
|
||||
assert_eq!(
|
||||
String::from_utf8(
|
||||
base64::engine::general_purpose::STANDARD.decode(e["content"].as_str().unwrap()).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
"帮我看下这个 panic"
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 跨语言等价性守卫 ====================
|
||||
//
|
||||
// 折叠是**等价变换**这条,靠两侧各一半守:
|
||||
// 这里(Rust):折叠输出必须与 fixtures/replay/folded.jsonl 逐字节一致;
|
||||
// UI (TS) :reduceBatch(raw) ≡ reduceBatch(folded)。
|
||||
// 任一侧改了折叠规则而没同步,这两条里必有一条红。
|
||||
// 素材换了以后跑 `cargo test regenerate_fold_fixture -- --ignored` 重生成。
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/replay")
|
||||
}
|
||||
|
||||
fn read_jsonl(path: &std::path::Path) -> Vec<Value> {
|
||||
std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("读不到 {}: {e}", path.display()))
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| serde_json::from_str(l).expect("fixture 不是合法 JSONL"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folding_the_fixture_matches_the_committed_output() {
|
||||
let dir = fixture_dir();
|
||||
let folded = fold_frames(&read_jsonl(&dir.join("raw.jsonl")));
|
||||
let expected = read_jsonl(&dir.join("folded.jsonl"));
|
||||
|
||||
assert_eq!(
|
||||
folded, expected,
|
||||
"折叠输出与 fixtures/replay/folded.jsonl 不一致。\
|
||||
\n改了折叠规则就跑 `cargo test regenerate_fold_fixture -- --ignored` 重生成,\
|
||||
\n并确认 UI 侧 foldEquivalence.test.ts 仍绿(等价性是这套方案的地基)。"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "素材/规则变更时手动重生成 fixture"]
|
||||
fn regenerate_fold_fixture() {
|
||||
let dir = fixture_dir();
|
||||
let folded = fold_frames(&read_jsonl(&dir.join("raw.jsonl")));
|
||||
let body: String = folded.iter().map(|f| format!("{f}\n")).collect();
|
||||
std::fs::write(dir.join("folded.jsonl"), body).expect("写 fixture");
|
||||
}
|
||||
|
||||
// ==================== 大字段护栏 ====================
|
||||
|
||||
fn big_tool_update(seq: u64, bytes: usize) -> Value {
|
||||
acp(
|
||||
json!({
|
||||
"sessionUpdate": "tool_call_update",
|
||||
"toolCallId": "t1",
|
||||
"status": "completed",
|
||||
"rawOutput": { "output": "x".repeat(bytes) },
|
||||
}),
|
||||
seq,
|
||||
)
|
||||
}
|
||||
|
||||
/// 护栏只在物化那一刻施加(Turn::guard),折叠本身保持等价
|
||||
fn materialized(frames: &[Value]) -> Vec<Value> {
|
||||
let mut turn = Turn { from: 0, to: 0, frames: fold_frames(frames) };
|
||||
turn.guard();
|
||||
turn.frames
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folding_alone_never_truncates_only_materialising_does() {
|
||||
let big = big_tool_update(5, 200_000);
|
||||
|
||||
let folded = fold_frames(&[big.clone()]);
|
||||
let stored = materialized(&[big]);
|
||||
|
||||
// 折叠输出必须原样保留(等价性契约);截断是物化那一步的事
|
||||
assert_eq!(folded[0]["data"]["update"]["rawOutput"]["output"].as_str().unwrap().len(), 200_000);
|
||||
assert!(stored[0]["data"]["update"]["rawOutput"]["output"].as_str().unwrap().len() < 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_tool_fields_are_truncated_in_place_and_marked_for_re_read() {
|
||||
let out = materialized(&[big_tool_update(5, 200_000)]);
|
||||
|
||||
let update = &out[0]["data"]["update"];
|
||||
// 结构不变(UI 的 toolResultText/presentToolCall 依赖字段形状)
|
||||
let text = update["rawOutput"]["output"].as_str().unwrap();
|
||||
assert!(text.len() < 2000, "行内只留头部: {}", text.len());
|
||||
assert!(text.ends_with('…'));
|
||||
assert_eq!(update["_meta"]["mcSrc"]["seq"], json!(5), "留下按 seq 回读的凭据");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_under_the_limit_are_left_alone_and_unmarked() {
|
||||
let out = materialized(&[big_tool_update(5, 100)]);
|
||||
|
||||
let update = &out[0]["data"]["update"];
|
||||
assert_eq!(update["rawOutput"]["output"].as_str().unwrap().len(), 100);
|
||||
assert!(update.get("_meta").is_none(), "没截断就不该留标记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_never_splits_a_multibyte_character() {
|
||||
// 全中文:1KB 边界必然落在字符中间,切错就会产出非法 UTF-8
|
||||
let out = materialized(&[acp(
|
||||
json!({ "sessionUpdate": "tool_call", "toolCallId": "t1",
|
||||
"rawInput": { "text": "中".repeat(5000) } }),
|
||||
1,
|
||||
)]);
|
||||
|
||||
let text = out[0]["data"]["update"]["rawInput"]["text"].as_str().unwrap();
|
||||
assert!(text.chars().all(|c| c == '中' || c == '…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_tool_frames_are_guarded() {
|
||||
// 正文再长也不截:模型输出本身就是要给用户看的内容
|
||||
let out = materialized(&[chunk("agent_message_chunk", &"字".repeat(20_000), 1, 1)]);
|
||||
|
||||
assert_eq!(text_of(&out[0]).chars().count(), 20_000);
|
||||
assert!(out[0]["data"]["update"].get("_meta").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_full_frame_is_read_back_by_seq_from_the_raw_journal() {
|
||||
use std::io::Write as _;
|
||||
let dir = temp_dir("readback");
|
||||
// events.jsonl:两轮原始帧;replay.jsonl:对应的轮记录
|
||||
let mut events = String::new();
|
||||
let full = big_tool_update(3, 200_000);
|
||||
for f in [lifecycle("user-input", 1), lifecycle("task-started", 2), full.clone(), lifecycle("task-ended", 4)] {
|
||||
events.push_str(&f.to_string());
|
||||
events.push('\n');
|
||||
}
|
||||
let src_end = events.len() as u64;
|
||||
for f in [lifecycle("user-input", 5), lifecycle("task-started", 6)] {
|
||||
events.push_str(&f.to_string());
|
||||
events.push('\n');
|
||||
}
|
||||
std::fs::write(dir.join("events.jsonl"), &events).unwrap();
|
||||
let mut turn = Turn { from: 1, to: 4, frames: fold_frames(&[full.clone()]) };
|
||||
turn.guard();
|
||||
let mut f = std::fs::File::create(dir.join("replay.jsonl")).unwrap();
|
||||
writeln!(f, "{}", turn.to_line(src_end)).unwrap();
|
||||
drop(f);
|
||||
|
||||
let got = read_frame_by_seq(&dir.join("replay.jsonl"), &dir.join("events.jsonl"), 3).unwrap();
|
||||
|
||||
assert_eq!(got["data"]["update"]["rawOutput"]["output"].as_str().unwrap().len(), 200_000);
|
||||
// 找不到的 seq 明确返回 None(UI 据此外显"原始记录已不可用")
|
||||
assert!(read_frame_by_seq(&dir.join("replay.jsonl"), &dir.join("events.jsonl"), 999).is_none());
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//
|
||||
// 上行统一走 invoke 命令(本文件底部,main.rs 注册)。
|
||||
|
||||
mod fold;
|
||||
pub mod frame;
|
||||
mod normalize;
|
||||
pub mod ohmy;
|
||||
@@ -224,11 +225,38 @@ pub async fn models_list(host: State<'_, DriverHost>) -> Result<Value, String> {
|
||||
host.get()?.models_list().await
|
||||
}
|
||||
|
||||
/// 打开会话:返回尾部回放窗口 `{frames, cursor, has_more}`。历史走返回值
|
||||
/// 而非 `frames:{sid}` 事件——返回值天生有序,不必依赖"监听先于命令"。
|
||||
#[tauri::command]
|
||||
pub async fn session_open(host: State<'_, DriverHost>, id: String) -> Result<(), String> {
|
||||
pub async fn session_open(host: State<'_, DriverHost>, id: String) -> Result<Value, String> {
|
||||
host.get()?.session_open(&id).await
|
||||
}
|
||||
|
||||
/// 往前翻页:cursor 之前的最多 limit 轮,形状对齐云端 mc_task_rounds
|
||||
/// (`{frames, next_cursor, has_more}`),UI 两条路径共用一套"加载更早"。
|
||||
#[tauri::command]
|
||||
pub async fn session_history(
|
||||
host: State<'_, DriverHost>,
|
||||
id: String,
|
||||
cursor: u64,
|
||||
limit: usize,
|
||||
) -> Result<Value, String> {
|
||||
host.get()?.session_history(&id, cursor, limit).await
|
||||
}
|
||||
|
||||
/// 回读单帧原文:物化时截断的工具大字段,展开卡片时按 seq 取全文。
|
||||
#[tauri::command]
|
||||
pub async fn session_frame(host: State<'_, DriverHost>, id: String, seq: u64) -> Result<Value, String> {
|
||||
host.get()?.session_frame(&id, seq).await
|
||||
}
|
||||
|
||||
/// 提问大纲:全量 user-input 条目(含未物化尾巴),条目自带轮起始偏移,
|
||||
/// UI 点到未加载的早期提问时拿它当 cursor 补历史。
|
||||
#[tauri::command]
|
||||
pub async fn session_outline(host: State<'_, DriverHost>, id: String) -> Result<Value, String> {
|
||||
host.get()?.session_outline(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn session_close(host: State<'_, DriverHost>, id: String) -> Result<(), String> {
|
||||
if let Ok(e) = host.get() {
|
||||
|
||||
@@ -487,6 +487,7 @@ fn bare_inner(tag: &str) -> Arc<Inner> {
|
||||
pending_questions: StdMutex::new(HashMap::new()),
|
||||
pending_perms: StdMutex::new(HashMap::new()),
|
||||
perm_tools: StdMutex::new(HashMap::new()),
|
||||
resume: StdMutex::new(HashMap::new()),
|
||||
},
|
||||
sub: SubagentState {
|
||||
subagents: StdMutex::new(HashMap::new()),
|
||||
@@ -517,6 +518,7 @@ fn bare_session(sid: &str) -> SessionState {
|
||||
model_name: String::new(),
|
||||
mode: "default".into(),
|
||||
title: String::new(),
|
||||
fold: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,7 +676,7 @@ fn replay_window_no_frame_loss() {
|
||||
let replay = inner.replay_open("s1");
|
||||
pusher.join().unwrap();
|
||||
let rseqs: Vec<u64> =
|
||||
replay.iter().filter_map(|f| f.get("seq").and_then(|v| v.as_u64())).collect();
|
||||
replay.frames.iter().filter_map(|f| f.get("seq").and_then(|v| v.as_u64())).collect();
|
||||
assert!(rseqs.len() >= 50, "回放至少含预置帧: {}", rseqs.len());
|
||||
assert_eq!(rseqs.first(), Some(&1));
|
||||
assert!(rseqs.windows(2).all(|w| w[1] == w[0] + 1), "回放帧 seq 不连续: {rseqs:?}");
|
||||
@@ -1324,3 +1326,172 @@ async fn e2e_readonly_subagent_stays_synchronous() {
|
||||
assert!(driver.0.sub.background_agents.lock().unwrap().is_empty());
|
||||
driver.stop();
|
||||
}
|
||||
|
||||
// ==================== 回放物化 / 尾部窗口 / 补录 ====================
|
||||
//
|
||||
// 折叠规则本身在 fold_tests.rs;这里守的是「壳把它接对了」:轮末物化、
|
||||
// 打开只读窗口、老会话迁移、补录偏移不重不丢。
|
||||
|
||||
/// 空闲会话(running=false),补录/物化路径要求
|
||||
fn idle_session(sid: &str) -> SessionState {
|
||||
let mut s = bare_session(sid);
|
||||
s.running = false;
|
||||
s
|
||||
}
|
||||
|
||||
fn push_one_turn(inner: &Arc<Inner>, sid: &str, prompt: &str, chunks: usize) {
|
||||
inner.push_frame(sid, |seq| frame::user_input(prompt, seq));
|
||||
inner.push_frame(sid, frame::task_started);
|
||||
for _ in 0..chunks {
|
||||
inner.push_frame(sid, |seq| frame::agent_thought("字", seq));
|
||||
}
|
||||
inner.push_frame(sid, frame::task_ended);
|
||||
}
|
||||
|
||||
/// user-input 帧的 content 是 base64(与云端上行同格式)
|
||||
fn prompt_of(f: &Value) -> String {
|
||||
use base64::Engine as _;
|
||||
let b64 = f["data"]["content"].as_str().unwrap();
|
||||
String::from_utf8(base64::engine::general_purpose::STANDARD.decode(b64).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn replay_lines(inner: &Arc<Inner>, sid: &str) -> Vec<Value> {
|
||||
let path = inner.data_dir.join(sid).join("replay.jsonl");
|
||||
std::fs::read_to_string(path)
|
||||
.map(|t| t.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_turn_is_materialised_as_one_folded_line() {
|
||||
let inner = bare_inner("materialize");
|
||||
inner.sess.sessions.lock().unwrap().insert("s1".into(), idle_session("s1"));
|
||||
|
||||
push_one_turn(&inner, "s1", "问题", 300);
|
||||
inner.journal_barrier();
|
||||
|
||||
let turns = replay_lines(&inner, "s1");
|
||||
assert_eq!(turns.len(), 1, "一轮一行");
|
||||
let frames = turns[0]["frames"].as_array().unwrap();
|
||||
// user-input + task-started + 300 碎片折成 1 帧 + task-ended
|
||||
assert_eq!(frames.len(), 4, "折叠后应只剩 4 帧: {frames:?}");
|
||||
assert_eq!(
|
||||
frames[2]["data"]["update"]["content"]["text"].as_str().unwrap().chars().count(),
|
||||
300
|
||||
);
|
||||
// src_end 必须精确落在这一轮最后一帧写完的位置
|
||||
let events = std::fs::metadata(inner.data_dir.join("s1").join("events.jsonl")).unwrap().len();
|
||||
assert_eq!(turns[0]["src_end"].as_u64(), Some(events));
|
||||
assert_eq!(turns[0]["to"].as_u64(), Some(303));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_reads_the_folded_window_not_the_raw_journal() {
|
||||
let inner = bare_inner("window");
|
||||
inner.sess.sessions.lock().unwrap().insert("s1".into(), idle_session("s1"));
|
||||
push_one_turn(&inner, "s1", "问题", 500);
|
||||
inner.journal_barrier();
|
||||
|
||||
let w = inner.replay_open("s1");
|
||||
|
||||
assert_eq!(w.frames.len(), 4, "打开拿到的是折叠帧,不是 503 帧原始流");
|
||||
assert!(!w.has_more);
|
||||
// 已物化的历史不会被再算一遍:补录不产生第二行
|
||||
assert_eq!(replay_lines(&inner, "s1").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmaterialised_legacy_journal_is_migrated_on_first_open_and_only_once() {
|
||||
let inner = bare_inner("migrate");
|
||||
let dir = inner.data_dir.join("s1");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// 老会话:只有 events.jsonl(两轮),没有 replay.jsonl
|
||||
let mut seq = 0u64;
|
||||
let mut lines = String::new();
|
||||
for turn in 0..2 {
|
||||
for f in [
|
||||
frame::user_input(&format!("第 {turn} 问"), { seq += 1; seq }),
|
||||
frame::task_started({ seq += 1; seq }),
|
||||
] {
|
||||
lines.push_str(&f.to_string());
|
||||
lines.push('\n');
|
||||
}
|
||||
for _ in 0..200 {
|
||||
lines.push_str(&frame::agent_thought("字", { seq += 1; seq }).to_string());
|
||||
lines.push('\n');
|
||||
}
|
||||
lines.push_str(&frame::task_ended({ seq += 1; seq }).to_string());
|
||||
lines.push('\n');
|
||||
}
|
||||
std::fs::write(dir.join("events.jsonl"), &lines).unwrap();
|
||||
inner.sess.sessions.lock().unwrap().insert("s1".into(), idle_session("s1"));
|
||||
|
||||
let first = inner.replay_open("s1");
|
||||
inner.journal_barrier();
|
||||
|
||||
assert_eq!(replay_lines(&inner, "s1").len(), 2, "两轮各物化一行");
|
||||
assert_eq!(first.frames.len(), 8, "窗口是折叠后的两轮");
|
||||
// seq 水位跟上历史,新帧不会与旧行撞号
|
||||
assert_eq!(inner.sess.sessions.lock().unwrap()["s1"].seq, seq);
|
||||
|
||||
// 第二次打开:不重复物化、内容一致(补录偏移写对了才可能)
|
||||
let second = inner.replay_open("s1");
|
||||
inner.journal_barrier();
|
||||
assert_eq!(replay_lines(&inner, "s1").len(), 2, "补录必须幂等");
|
||||
assert_eq!(second.frames, first.frames);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_open_window_keeps_only_the_newest_turns_and_pages_back_from_the_cursor() {
|
||||
let inner = bare_inner("paging");
|
||||
inner.sess.sessions.lock().unwrap().insert("s1".into(), idle_session("s1"));
|
||||
for i in 0..(crate::driver::fold::TAIL_TURNS + 5) {
|
||||
push_one_turn(&inner, "s1", &format!("第 {i} 问"), 3);
|
||||
}
|
||||
inner.journal_barrier();
|
||||
|
||||
let w = inner.replay_open("s1");
|
||||
|
||||
let turns = replay_lines(&inner, "s1");
|
||||
assert_eq!(turns.len(), crate::driver::fold::TAIL_TURNS + 5);
|
||||
assert_eq!(w.frames.len(), crate::driver::fold::TAIL_TURNS * 4, "只回最近 TAIL_TURNS 轮");
|
||||
assert!(w.has_more, "前面还有 5 轮");
|
||||
assert!(w.cursor > 0);
|
||||
// 窗口第一帧是第 5 问(0..24 共 25 轮,尾 20 轮从第 5 轮起)
|
||||
assert_eq!(prompt_of(&w.frames[0]), "第 5 问");
|
||||
|
||||
// 往前翻一轮
|
||||
let (older, has_more) = crate::driver::fold::read_before(
|
||||
&inner.data_dir.join("s1").join("replay.jsonl"),
|
||||
w.cursor,
|
||||
1,
|
||||
);
|
||||
assert_eq!(older.len(), 1);
|
||||
assert!(has_more);
|
||||
assert_eq!(prompt_of(&older[0].frames[0]), "第 4 问");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_still_running_stays_raw_and_is_not_materialised_early() {
|
||||
let inner = bare_inner("openturn");
|
||||
inner.sess.sessions.lock().unwrap().insert("s1".into(), idle_session("s1"));
|
||||
push_one_turn(&inner, "s1", "已完成", 5);
|
||||
// 未闭合轮:没有 task-ended
|
||||
inner.push_frame("s1", |seq| frame::user_input("进行中", seq));
|
||||
inner.push_frame("s1", frame::task_started);
|
||||
inner.push_frame("s1", |seq| frame::agent_thought("正在想", seq));
|
||||
inner.journal_barrier();
|
||||
|
||||
assert_eq!(replay_lines(&inner, "s1").len(), 1, "只物化已闭合的那一轮");
|
||||
|
||||
// 打开:窗口 = 已物化的一轮 + 未物化尾巴(折叠后),按 seq 连续
|
||||
if let Some(s) = inner.sess.sessions.lock().unwrap().get_mut("s1") {
|
||||
s.running = true; // 运行中:补录必须让路
|
||||
}
|
||||
let w = inner.replay_open("s1");
|
||||
let seqs: Vec<u64> =
|
||||
w.frames.iter().filter_map(|f| f.get("seq").and_then(|v| v.as_u64())).collect();
|
||||
// 已物化轮折成 user-input(1) + started(2) + 正文(3) + ended(8),
|
||||
// 后接未物化尾巴 9/10/11——折叠帧取首帧 seq,两段无缝衔接
|
||||
assert_eq!(seqs, vec![1, 2, 3, 8, 9, 10, 11]);
|
||||
}
|
||||
|
||||
+347
-84
@@ -13,6 +13,7 @@ use std::sync::{Arc, Mutex as StdMutex};
|
||||
use base64::Engine as _;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::fold::{self, TurnFold};
|
||||
use super::frame::{self, PermOutcome, SessionStatus};
|
||||
use super::ohmy::{Inner, OhmyDriver};
|
||||
use super::transport::JournalMsg;
|
||||
@@ -161,6 +162,10 @@ pub(super) struct SessionState {
|
||||
pub(super) model_name: String,
|
||||
pub(super) mode: String,
|
||||
pub(super) title: String,
|
||||
/// 本轮的增量折叠(见 fold.rs)。push_frame 逐帧喂,task-ended 时取走
|
||||
/// 投给写线程物化成 replay.jsonl 的一行。内存里只留折叠后的百来帧,
|
||||
/// 十万 token 的长轮次也不会把原始碎片堆住。
|
||||
pub(super) fold: TurnFold,
|
||||
}
|
||||
|
||||
/// 会话态锁组:会话表、待发帧缓冲与审批/提问簿记。
|
||||
@@ -189,6 +194,18 @@ pub(super) struct SessionsState {
|
||||
pub(super) pending_perms: StdMutex<HashMap<String, String>>,
|
||||
/// 审批请求的工具名(request_id → tool;"始终允许"回写记忆集用)
|
||||
pub(super) perm_tools: StdMutex<HashMap<String, String>>,
|
||||
/// 后台 resume 的完成广播(sid → 结果)。打开会话不再串行等引擎握手,
|
||||
/// 但所有上行命令必须先经 ensure_engine_ready 等它就绪。
|
||||
pub(super) resume:
|
||||
StdMutex<HashMap<String, tokio::sync::watch::Receiver<Option<Result<(), String>>>>>,
|
||||
}
|
||||
|
||||
/// 打开会话时下发的回放窗口。cursor 是窗口最早那一轮在 replay.jsonl 里的
|
||||
/// 字节偏移,UI 拿它调 session_history 往前翻;has_more 为假即已到会话开头。
|
||||
pub(super) struct ReplayWindow {
|
||||
pub(super) frames: Vec<Value>,
|
||||
pub(super) cursor: u64,
|
||||
pub(super) has_more: bool,
|
||||
}
|
||||
|
||||
/// 从 pos 起补读日志新增部分,解析出的帧追加到 out,pos 前移。
|
||||
@@ -368,6 +385,7 @@ impl OhmyDriver {
|
||||
model_name: model_name.to_string(),
|
||||
mode: "default".into(),
|
||||
title: String::new(),
|
||||
fold: TurnFold::default(),
|
||||
},
|
||||
);
|
||||
// 契约 5:新建未运行的会话是 created,不是 finished(否则侧栏打勾、桌宠庆祝)
|
||||
@@ -384,102 +402,241 @@ impl OhmyDriver {
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn session_open(&self, id: &str) -> Result<(), String> {
|
||||
// 幂等:确保 resume + 标记 opened + 回放日志
|
||||
/// 打开会话:立刻返回尾部回放窗口,引擎 resume 转后台。
|
||||
///
|
||||
/// 顺序反转的动机——历史帧是壳自己的 sidecar 数据,本来不依赖引擎;
|
||||
/// 旧实现却先 await `session/create{resume}`,而引擎那一步要读回整份
|
||||
/// messages.jsonl 并用 tiktoken 重算 context_used(实测 1.4MB 历史
|
||||
/// 236ms),这段时间 UI 只有"连接中…"、一个字都画不出来。现在先出图,
|
||||
/// resume 完成/失败经 conn-status 通知,上行命令由 ensure_engine_ready
|
||||
/// 挡住(等待而非报错),用户观感是"内容秒出、输入框稍后可用"。
|
||||
///
|
||||
/// 历史帧改走命令返回值而不是 `frames:{sid}` 事件:返回值天生有序,
|
||||
/// "监听先于命令"那条约束对历史部分自然消失(实时流仍走事件,契约不变)。
|
||||
pub async fn session_open(&self, id: &str) -> Result<Value, String> {
|
||||
let need_create = {
|
||||
let sessions = self.0.sess.sessions.lock_ok();
|
||||
!sessions.get(id).map(|s| s.created).unwrap_or(false)
|
||||
};
|
||||
let mut resume_ctx: Option<(i64, i64)> = None;
|
||||
if need_create {
|
||||
let meta = self.read_sidecar(id);
|
||||
let is_child =
|
||||
meta.get("parent").and_then(|v| v.as_str()).map(|p| !p.is_empty()).unwrap_or(false);
|
||||
let mut engine_id = meta
|
||||
let engine_id = meta
|
||||
.get("engine_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|e| !e.is_empty())
|
||||
.unwrap_or(id)
|
||||
.to_string();
|
||||
if !is_child {
|
||||
// 有历史则 resume 带全参(缺参会回落进程默认值);空会话
|
||||
// resume 必失败,改全新 create 换绑 engine_id(壳 sid 不变)。
|
||||
// 模型已从配置移除时不带 model,退化引擎默认(不阻断打开)
|
||||
let mode = meta.get("mode").and_then(|v| v.as_str()).unwrap_or("default");
|
||||
let has_history = self.engine_session_exists(&engine_id).await;
|
||||
let mut workdir = meta.get("workdir").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
if workdir.is_empty() {
|
||||
// 兼容没有 workdir 的旧 sidecar;不能留空让引擎隐式继承
|
||||
// Desktop 引擎进程 cwd,否则 resume 后会话会漂到进程目录。
|
||||
workdir = crate::config::home_dir()
|
||||
.map(|h| h.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
// 先登记会话态(零 RPC):push_frame 有落点、replay_open 查得到
|
||||
// fold/running,后台 resume 也要靠它回写 engine_id。
|
||||
// 子代理子会话是壳侧实体(仅回放),created 直接为真、不向引擎 resume。
|
||||
{
|
||||
let mut sessions = self.0.sess.sessions.lock_ok();
|
||||
let entry = sessions.entry(id.to_string()).or_insert(SessionState {
|
||||
seq: 0,
|
||||
running: false,
|
||||
created: is_child,
|
||||
engine_id: engine_id.clone(),
|
||||
opened: false,
|
||||
open_tools: HashMap::new(),
|
||||
model_text: String::new(),
|
||||
last_event_seq: 0,
|
||||
context_usage: None,
|
||||
workdir: meta.get("workdir").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
model_name: meta.get("model_name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
mode: meta.get("mode").and_then(|v| v.as_str()).unwrap_or("default").to_string(),
|
||||
title: meta.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
fold: TurnFold::default(),
|
||||
});
|
||||
entry.engine_id = engine_id.clone();
|
||||
if is_child {
|
||||
entry.created = true;
|
||||
}
|
||||
let mut params = engine_session_create_params(
|
||||
&workdir,
|
||||
has_history.then_some(engine_id.as_str()),
|
||||
None,
|
||||
mode,
|
||||
);
|
||||
let model_name = meta.get("model_name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Ok(model_id) = self.model_id_of(model_name) {
|
||||
params["model"] = json!(model_id);
|
||||
}
|
||||
let result = self.rpc("session/create", params).await?;
|
||||
if let Some(e) = result.get("session_id").and_then(|v| v.as_str()) {
|
||||
engine_id = e.to_string();
|
||||
}
|
||||
if engine_id != id {
|
||||
let e = engine_id.clone();
|
||||
self.write_sidecar(id, |m| m["engine_id"] = json!(e));
|
||||
}
|
||||
// resume 结果带恢复历史的占用估计,立即可显示(296176a)
|
||||
resume_ctx = Some((
|
||||
result.get("context_used").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
result.get("context_window").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
));
|
||||
}
|
||||
// 子代理子会话是壳侧实体(仅回放),登记但不向引擎 resume
|
||||
let mut sessions = self.0.sess.sessions.lock_ok();
|
||||
let entry = sessions.entry(id.to_string()).or_insert(SessionState {
|
||||
seq: 0,
|
||||
running: false,
|
||||
created: true,
|
||||
engine_id: engine_id.clone(),
|
||||
opened: false,
|
||||
open_tools: HashMap::new(),
|
||||
model_text: String::new(),
|
||||
last_event_seq: 0,
|
||||
context_usage: None,
|
||||
workdir: meta.get("workdir").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
model_name: meta.get("model_name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
mode: meta.get("mode").and_then(|v| v.as_str()).unwrap_or("default").to_string(),
|
||||
title: meta.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
});
|
||||
entry.created = true;
|
||||
entry.engine_id = engine_id.clone();
|
||||
drop(sessions);
|
||||
if let Some((used, window)) = resume_ctx.take() {
|
||||
self.0.push_usage(id, used, window);
|
||||
if !is_child {
|
||||
self.spawn_resume(id, meta, engine_id);
|
||||
}
|
||||
}
|
||||
// 回放日志(重开页面/重连:整份重放)。磁盘读与 flush 屏障都在
|
||||
// 阻塞线程上做(replay_open),不占 tokio 运行时;无缺口保证见
|
||||
// replay_open 注释。
|
||||
let journal = {
|
||||
// 磁盘读与 flush 屏障都在阻塞线程上做,不占 tokio 运行时;
|
||||
// 无缺口保证见 replay_open 注释。
|
||||
let window = {
|
||||
let inner = self.0.clone();
|
||||
let sid = id.to_string();
|
||||
tokio::task::spawn_blocking(move || inner.replay_open(&sid))
|
||||
.await
|
||||
.map_err(|e| format!("回放任务失败: {e}"))?
|
||||
};
|
||||
self.0.app.emit_json(&format!("conn-status:{id}"), json!({ "text": "已连接", "connected": true }));
|
||||
if !journal.is_empty() {
|
||||
self.0.app.emit_json(&format!("frames:{id}"), Value::Array(journal));
|
||||
// 已就绪(新建会话/子会话)直接报连接;等 resume 的由后台任务报
|
||||
if self.0.sess.sessions.lock_ok().get(id).map(|s| s.created).unwrap_or(false) {
|
||||
self.0
|
||||
.app
|
||||
.emit_json(&format!("conn-status:{id}"), json!({ "text": "已连接", "connected": true }));
|
||||
} else {
|
||||
self.0.app.emit_json(
|
||||
&format!("conn-status:{id}"),
|
||||
json!({ "text": "正在恢复会话…", "connected": false }),
|
||||
);
|
||||
}
|
||||
Ok(json!({
|
||||
"frames": window.frames,
|
||||
"cursor": window.cursor,
|
||||
"has_more": window.has_more,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 后台 resume:引擎握手挪出打开路径。结果经 watch 广播给
|
||||
/// ensure_engine_ready,并经 conn-status 外显(失败文案不能吞)。
|
||||
fn spawn_resume(&self, id: &str, meta: Value, engine_id: String) {
|
||||
let (tx, rx) = tokio::sync::watch::channel::<Option<Result<(), String>>>(None);
|
||||
self.0.sess.resume.lock_ok().insert(id.to_string(), rx);
|
||||
let me = self.clone();
|
||||
let sid = id.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let r = me.resume_engine(&sid, &meta, engine_id).await;
|
||||
let status = match &r {
|
||||
Ok(()) => json!({ "text": "已连接", "connected": true }),
|
||||
Err(e) => json!({ "text": format!("⚠ 会话恢复失败: {e}"), "connected": false }),
|
||||
};
|
||||
let _ = tx.send(Some(r));
|
||||
me.0.app.emit_json(&format!("conn-status:{sid}"), status);
|
||||
});
|
||||
}
|
||||
|
||||
/// 引擎侧会话恢复。有历史则 resume 带全参(缺参会回落进程默认值);
|
||||
/// 空会话 resume 必失败,改全新 create 换绑 engine_id(壳 sid 不变)。
|
||||
/// 模型已从配置移除时不带 model,退化引擎默认(不阻断打开)。
|
||||
async fn resume_engine(
|
||||
&self,
|
||||
id: &str,
|
||||
meta: &Value,
|
||||
mut engine_id: String,
|
||||
) -> Result<(), String> {
|
||||
let mode = meta.get("mode").and_then(|v| v.as_str()).unwrap_or("default");
|
||||
let has_history = self.engine_session_exists(&engine_id).await;
|
||||
let mut workdir = meta.get("workdir").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
if workdir.is_empty() {
|
||||
// 兼容没有 workdir 的旧 sidecar;不能留空让引擎隐式继承
|
||||
// Desktop 引擎进程 cwd,否则 resume 后会话会漂到进程目录。
|
||||
workdir =
|
||||
crate::config::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
}
|
||||
let mut params = engine_session_create_params(
|
||||
&workdir,
|
||||
has_history.then_some(engine_id.as_str()),
|
||||
None,
|
||||
mode,
|
||||
);
|
||||
let model_name = meta.get("model_name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Ok(model_id) = self.model_id_of(model_name) {
|
||||
params["model"] = json!(model_id);
|
||||
}
|
||||
let result = self.rpc("session/create", params).await?;
|
||||
if let Some(e) = result.get("session_id").and_then(|v| v.as_str()) {
|
||||
engine_id = e.to_string();
|
||||
}
|
||||
if engine_id != id {
|
||||
let e = engine_id.clone();
|
||||
let inner = self.0.clone();
|
||||
let sid = id.to_string();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
inner.write_sidecar(&sid, |m| m["engine_id"] = json!(e))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
if let Some(s) = self.0.sess.sessions.lock_ok().get_mut(id) {
|
||||
s.created = true;
|
||||
s.engine_id = engine_id;
|
||||
}
|
||||
// resume 结果带恢复历史的占用估计,立即可显示(296176a)
|
||||
self.0.push_usage(
|
||||
id,
|
||||
result.get("context_used").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
result.get("context_window").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 上行命令的引擎就绪门:后台 resume 未完成时**等待**而不是报错
|
||||
/// (用户在恢复期间点发送不该丢消息)。会话不存在或恢复失败即上抛。
|
||||
pub(super) async fn ensure_engine_ready(&self, id: &str) -> Result<(), String> {
|
||||
if self.0.sess.sessions.lock_ok().get(id).map(|s| s.created).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(mut rx) = self.0.sess.resume.lock_ok().get(id).cloned() else {
|
||||
return Err("会话未打开".into());
|
||||
};
|
||||
loop {
|
||||
// borrow 的守卫必须在 await 前落地,否则跨 await 持锁
|
||||
let current = rx.borrow().clone();
|
||||
if let Some(r) = current {
|
||||
return r;
|
||||
}
|
||||
if rx.changed().await.is_err() {
|
||||
return Err("会话恢复中断".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 往前翻页:cursor 之前的最多 limit 轮(cursor = replay.jsonl 的轮起始
|
||||
/// 字节偏移,与 session_open/大纲同一坐标系)。
|
||||
pub async fn session_history(&self, id: &str, cursor: u64, limit: usize) -> Result<Value, String> {
|
||||
let inner = self.0.clone();
|
||||
let sid = id.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let path = inner.data_dir.join(&sid).join("replay.jsonl");
|
||||
let (turns, has_more) = fold::read_before(&path, cursor, limit.clamp(1, 50));
|
||||
let next = turns.first().map(|t| t.offset).unwrap_or(0);
|
||||
let frames: Vec<Value> = turns.into_iter().flat_map(|t| t.frames).collect();
|
||||
json!({ "frames": frames, "next_cursor": next, "has_more": has_more })
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("读取历史失败: {e}"))
|
||||
}
|
||||
|
||||
/// 回读单帧原文:物化时被截断的工具大字段,展开卡片时按 seq 取全文。
|
||||
/// 完整原帧一直在 events.jsonl 里(审计源),所以不需要第二份存储;
|
||||
/// 日志若因保留策略被清理过,明确报错而不是静默给半截。
|
||||
pub async fn session_frame(&self, id: &str, seq: u64) -> Result<Value, String> {
|
||||
let inner = self.0.clone();
|
||||
let sid = id.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let dir = inner.data_dir.join(&sid);
|
||||
fold::read_frame_by_seq(&dir.join("replay.jsonl"), &dir.join("events.jsonl"), seq)
|
||||
.ok_or_else(|| "原始记录已不可用(帧日志可能已被清理)".to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("回读原始帧失败: {e}"))?
|
||||
}
|
||||
|
||||
/// 提问大纲:全量扫 replay.jsonl 的 user-input 帧 + 尚未物化的尾巴。
|
||||
/// 折叠后的文件只有几十 KB~数 MB,一次顺序读即可;条目自带轮起始偏移,
|
||||
/// UI 点到未加载的早期提问时拿它当 cursor 补历史。
|
||||
pub async fn session_outline(&self, id: &str) -> Result<Value, String> {
|
||||
let inner = self.0.clone();
|
||||
let sid = id.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let dir = inner.data_dir.join(&sid);
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let mut src_end = 0u64;
|
||||
for (offset, line) in fold::scan_lines(&dir.join("replay.jsonl")) {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&line) {
|
||||
src_end = v.get("src_end").and_then(|x| x.as_u64()).unwrap_or(src_end);
|
||||
}
|
||||
out.extend(fold::outline_of_line(offset, &line));
|
||||
}
|
||||
// 未物化的尾巴(当前轮/崩溃残留)也要出现在大纲里,否则刚问完的
|
||||
// 那一条不在目录中。offset 给 0:这段本来就在打开窗口内,不需要翻页
|
||||
let mut pos = src_end;
|
||||
let mut tail: Vec<Value> = Vec::new();
|
||||
read_journal_tail(&dir.join("events.jsonl"), &mut pos, &mut tail);
|
||||
out.extend(tail.iter().filter_map(|f| fold::outline_entry(0, f)));
|
||||
json!(out)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("读取大纲失败: {e}"))
|
||||
}
|
||||
|
||||
pub async fn session_close(&self, id: &str) {
|
||||
if let Some(s) = self.0.sess.sessions.lock_ok().get_mut(id) {
|
||||
s.opened = false;
|
||||
@@ -604,6 +761,9 @@ impl OhmyDriver {
|
||||
// ==================== 对话 ====================
|
||||
|
||||
pub async fn session_send(&self, id: &str, ftype: &str, payload: Value) -> Result<(), String> {
|
||||
// 打开会话已不等引擎握手(见 session_open),上行到这里必须等它就绪:
|
||||
// 等待而不是报错——用户在恢复期间敲下的消息不该丢
|
||||
self.ensure_engine_ready(id).await?;
|
||||
match ftype {
|
||||
"user-input" => {
|
||||
let content_b64 = payload.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@@ -789,6 +949,8 @@ impl OhmyDriver {
|
||||
}
|
||||
|
||||
pub async fn session_call(&self, id: &str, kind: &str, payload: Value) -> Result<Value, String> {
|
||||
// 同 session_send:引擎侧查询/切换都得等后台 resume 落地
|
||||
self.ensure_engine_ready(id).await?;
|
||||
match kind {
|
||||
"session_set_model" => {
|
||||
let name = payload.get("model").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@@ -1144,12 +1306,83 @@ impl Inner {
|
||||
let _ = self
|
||||
.transport.journal_tx
|
||||
.send(JournalMsg::Append { sid: sid.to_string(), line: f.to_string() });
|
||||
// 折叠累积与物化跟着帧走:轮末(task-ended,契约 5 的和解点——正常
|
||||
// 结束、出错、本地和解都经它)把这一轮折叠成 replay.jsonl 的一行。
|
||||
// 与 Append 同在通道内顺序投递,写线程据此取到准确的 events 偏移。
|
||||
s.fold.push(&f);
|
||||
if fold::is_turn_end(&f) {
|
||||
let turn = s.fold.take();
|
||||
if !turn.frames.is_empty() {
|
||||
let _ = self.transport.journal_tx.send(JournalMsg::Materialize {
|
||||
sid: sid.to_string(),
|
||||
turn,
|
||||
src_end: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
if s.opened {
|
||||
self.sess.batch.lock_ok().entry(sid.to_string()).or_default().push(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// 回放打开(阻塞线程调用):返回完整回放帧并置 opened=true 接实时流。
|
||||
/// 把 events.jsonl 中 `from` 之后的完整轮折叠物化进 replay.jsonl。
|
||||
/// 老会话首次打开(无 replay.jsonl,from = 0)即一次性迁移;进程崩溃
|
||||
/// 残留的完整轮即补录。流式单遍,不把整份日志读进内存——23MB 的
|
||||
/// 病态 journal 也只占一行的内存。返回是否写入过内容。
|
||||
fn catch_up(&self, sid: &str, events: &Path, from: u64) -> bool {
|
||||
use std::io::{BufRead as _, BufReader, Seek as _, SeekFrom};
|
||||
let Ok(mut f) = std::fs::File::open(events) else { return false };
|
||||
if f.seek(SeekFrom::Start(from)).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut r = BufReader::new(f);
|
||||
let mut acc = TurnFold::default();
|
||||
let mut off = from;
|
||||
let mut wrote = false;
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match r.read_line(&mut line) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
// 半行(并发追加下可能读到)留给下次,绝不按残行切轮
|
||||
if !line.ends_with('\n') {
|
||||
break;
|
||||
}
|
||||
off += n as u64;
|
||||
let Ok(v) = serde_json::from_str::<Value>(line.trim_end()) else { continue };
|
||||
let end = fold::is_turn_end(&v);
|
||||
acc.push(&v);
|
||||
if end {
|
||||
let _ = self.transport.journal_tx.send(JournalMsg::Materialize {
|
||||
sid: sid.to_string(),
|
||||
turn: acc.take(),
|
||||
src_end: Some(off),
|
||||
});
|
||||
wrote = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 末尾未闭合的一段也物化:补录只在会话空闲时做,它不会再有新帧,
|
||||
// 留着只会每次打开重折一遍
|
||||
if !acc.is_empty() {
|
||||
let _ = self.transport.journal_tx.send(JournalMsg::Materialize {
|
||||
sid: sid.to_string(),
|
||||
turn: acc.take(),
|
||||
src_end: Some(off),
|
||||
});
|
||||
wrote = true;
|
||||
}
|
||||
wrote
|
||||
}
|
||||
|
||||
/// 回放打开(阻塞线程调用):返回**尾部窗口**并置 opened=true 接实时流。
|
||||
///
|
||||
/// 窗口 = replay.jsonl 的最近若干轮(折叠后,见 fold.rs)+ events.jsonl
|
||||
/// 里尚未物化的尾巴。更早的历史由 session_history 按 cursor 往前翻——
|
||||
/// 打开成本自此与会话总长度无关,只与窗口大小有关。
|
||||
///
|
||||
/// 旧实现的缺口:opened=false 期间到达的帧只入日志不入缓冲,读完日志
|
||||
/// 才置 opened=true——窗口内已落盘的帧既不在回放结果也不进 batch,
|
||||
/// UI 要重开会话才能看到。现分两段消除缺口:
|
||||
@@ -1161,32 +1394,62 @@ impl Inner {
|
||||
/// 回放与实时流按 seq 无缝衔接、无重复。
|
||||
/// 死锁安全:写线程只碰文件系统,不拿 sessions 锁;锁内屏障只等
|
||||
/// 窗口内的少量尾帧,阻塞极短。
|
||||
pub(super) fn replay_open(&self, sid: &str) -> Vec<Value> {
|
||||
pub(super) fn replay_open(&self, sid: &str) -> ReplayWindow {
|
||||
if let Some(s) = self.sess.sessions.lock_ok().get_mut(sid) {
|
||||
s.opened = false;
|
||||
}
|
||||
// 清批量缓冲:其中的帧已在日志里,会随回放送达,留着会重帧
|
||||
self.sess.batch.lock_ok().remove(sid);
|
||||
let path = self.data_dir.join(sid).join("events.jsonl");
|
||||
let mut frames: Vec<Value> = Vec::new();
|
||||
let mut pos: u64 = 0;
|
||||
let dir = self.data_dir.join(sid);
|
||||
let (replay, events) = (dir.join("replay.jsonl"), dir.join("events.jsonl"));
|
||||
|
||||
self.journal_barrier();
|
||||
read_journal_tail(&path, &mut pos, &mut frames);
|
||||
// 补录/迁移只在会话空闲且本进程没有累积中的轮次时做:那时 events.jsonl
|
||||
// 里 src_end 之后的完整轮必然来自更早的进程,不可能与实时物化撞车
|
||||
let idle = self
|
||||
.sess
|
||||
.sessions
|
||||
.lock_ok()
|
||||
.get(sid)
|
||||
.map(|s| s.fold.is_empty() && !s.running)
|
||||
.unwrap_or(true);
|
||||
if idle {
|
||||
let (last, _) = fold::read_before(&replay, u64::MAX, 1);
|
||||
let from = last.last().map(|t| t.src_end).unwrap_or(0);
|
||||
if self.catch_up(sid, &events, from) {
|
||||
self.journal_barrier(); // 等补录落盘再取窗口
|
||||
}
|
||||
}
|
||||
|
||||
let (turns, has_more) = fold::read_tail(&replay);
|
||||
let cursor = turns.first().map(|t| t.offset).unwrap_or(0);
|
||||
let src_end = turns.last().map(|t| t.src_end).unwrap_or(0);
|
||||
let mut high = turns.last().map(|t| t.to).unwrap_or(0);
|
||||
let mut frames: Vec<Value> = turns.into_iter().flat_map(|t| t.frames).collect();
|
||||
|
||||
// 未物化的尾巴(通常是当前未闭合轮;补录跳过时是全部原始帧)
|
||||
let mut pos = src_end;
|
||||
let mut raw: Vec<Value> = Vec::new();
|
||||
read_journal_tail(&events, &mut pos, &mut raw);
|
||||
let mut sessions = self.sess.sessions.lock_ok();
|
||||
if let Some(s) = sessions.get_mut(sid) {
|
||||
self.journal_barrier();
|
||||
read_journal_tail(&path, &mut pos, &mut frames);
|
||||
read_journal_tail(&events, &mut pos, &mut raw);
|
||||
s.opened = true;
|
||||
// seq 取 max 防序号回卷(引擎侧回放/历史日志行数与内存编号对齐)
|
||||
let replay_high = frames
|
||||
.iter()
|
||||
.filter_map(|f| f.get("seq").and_then(|v| v.as_u64()))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(frames.len() as u64);
|
||||
s.seq = s.seq.max(replay_high);
|
||||
high = high.max(
|
||||
raw.iter().filter_map(|f| f.get("seq").and_then(|v| v.as_u64())).max().unwrap_or(0),
|
||||
);
|
||||
if src_end == 0 {
|
||||
// 无物化记录时 raw 即整份日志,行数是极老 journal(可能缺 seq)
|
||||
// 的兜底水位
|
||||
high = high.max(raw.len() as u64);
|
||||
}
|
||||
s.seq = s.seq.max(high);
|
||||
}
|
||||
frames
|
||||
drop(sessions);
|
||||
frames.extend(fold::fold_frames(&raw));
|
||||
ReplayWindow { frames, cursor, has_more }
|
||||
}
|
||||
|
||||
pub(super) fn emit_session_event(&self, sid: &str, status: &str) {
|
||||
|
||||
@@ -132,6 +132,7 @@ impl Inner {
|
||||
model_name: model_name.clone(),
|
||||
mode: "default".into(),
|
||||
title: title.clone(),
|
||||
fold: Default::default(),
|
||||
},
|
||||
);
|
||||
self.write_sidecar(child_sid, |m| {
|
||||
|
||||
@@ -66,6 +66,12 @@ pub(super) struct TransportState {
|
||||
pub(super) enum JournalMsg {
|
||||
/// 追加一行(已含换行前的完整 JSON;按到达顺序 == seq 顺序写入)
|
||||
Append { sid: String, line: String },
|
||||
/// 轮结束物化:折叠后的一轮写进 replay.jsonl(一行一轮,见 fold.rs)。
|
||||
/// `src_end` = None 时由写线程取 events.jsonl 当前长度——通道内该轮的
|
||||
/// Append 必然先于本消息到达,单线程顺序处理,此刻的文件长度精确等于
|
||||
/// "这一轮最后一帧写完"的位置。补录/迁移的旧轮后面还跟着别的帧,
|
||||
/// 必须显式给出那一轮自己的结束偏移。
|
||||
Materialize { sid: String, turn: super::fold::Turn, src_end: Option<u64> },
|
||||
/// 关闭并移除该会话的缓存句柄;带 ack 时处理完即应答——
|
||||
/// 删除会话目录前必须等到(Windows 上打开中的文件删不掉目录)
|
||||
Close { sid: String, ack: Option<std::sync::mpsc::Sender<()>> },
|
||||
@@ -118,6 +124,32 @@ pub(super) fn spawn_journal_writer(data_dir: PathBuf) -> mpsc::UnboundedSender<J
|
||||
handles.remove(&sid);
|
||||
}
|
||||
}
|
||||
// 一轮一次,不值得为它留句柄(留了反而是 Windows 删目录时
|
||||
// 的又一个待关文件):每轮 open-append-close。
|
||||
JournalMsg::Materialize { sid, mut turn, src_end } => {
|
||||
// 截断只发生在写进 replay.jsonl 的这一刻(见 Turn::guard)
|
||||
turn.guard();
|
||||
let dir = data_dir.join(&sid);
|
||||
let end = match src_end {
|
||||
Some(v) => v,
|
||||
None => std::fs::metadata(dir.join("events.jsonl"))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0),
|
||||
};
|
||||
match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(dir.join("replay.jsonl"))
|
||||
{
|
||||
Ok(mut f) => {
|
||||
if let Err(e) = writeln!(f, "{}", turn.to_line(end)) {
|
||||
eprintln!("[desktop] 写回放日志失败({sid}): {e}");
|
||||
}
|
||||
}
|
||||
// 写不成只是这轮下次打开要现折,不影响正确性
|
||||
Err(e) => eprintln!("[desktop] 打开回放日志失败({sid}): {e}"),
|
||||
}
|
||||
}
|
||||
JournalMsg::Close { sid, ack } => {
|
||||
handles.remove(&sid);
|
||||
if let Some(a) = ack {
|
||||
@@ -229,6 +261,7 @@ impl OhmyDriver {
|
||||
pending_questions: StdMutex::new(HashMap::new()),
|
||||
pending_perms: StdMutex::new(HashMap::new()),
|
||||
perm_tools: StdMutex::new(HashMap::new()),
|
||||
resume: StdMutex::new(HashMap::new()),
|
||||
},
|
||||
sub: SubagentState {
|
||||
subagents: StdMutex::new(HashMap::new()),
|
||||
|
||||
@@ -801,6 +801,9 @@ fn main() {
|
||||
driver::session_patch,
|
||||
driver::models_list,
|
||||
driver::session_open,
|
||||
driver::session_history,
|
||||
driver::session_outline,
|
||||
driver::session_frame,
|
||||
driver::session_close,
|
||||
driver::session_send,
|
||||
driver::session_call,
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
"allow-session-patch",
|
||||
"allow-models-list",
|
||||
"allow-session-open",
|
||||
"allow-session-history",
|
||||
"allow-session-outline",
|
||||
"allow-session-frame",
|
||||
"allow-session-close",
|
||||
"allow-session-send",
|
||||
"allow-session-call",
|
||||
|
||||
@@ -823,6 +823,8 @@ function SessionViewer({ id, workdir }: { id: string; workdir?: string }) {
|
||||
useEffect(() => {
|
||||
const conn = connect(id, {
|
||||
onFrames: (batch) => setChat((s) => reduceBatch(s, batch)),
|
||||
// 子会话只读回放:尾部窗口够看完整过程,不做"加载更早"
|
||||
onHistory: (page) => setChat((s) => reduceBatch(s, page.frames)),
|
||||
onStatus: (text) => setStatus(text),
|
||||
});
|
||||
connRef.current = conn;
|
||||
|
||||
+141
-10
@@ -4,6 +4,7 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
@@ -17,9 +18,12 @@ import {
|
||||
HeaderMenu,
|
||||
LogList,
|
||||
MONO,
|
||||
OutlineNav,
|
||||
TaskPanel,
|
||||
ViewHeader,
|
||||
outlineEntries,
|
||||
type MenuState,
|
||||
type OutlineEntry,
|
||||
} from "./components";
|
||||
import { Composer, QueuedChip, RunningBar } from "./composer";
|
||||
import { IconArchive, IconChat, IconCheck, IconChevronDown, IconFolder, IconInfo, IconShield, IconTaskDone, IconX } from "./icons";
|
||||
@@ -427,20 +431,121 @@ export function ChatView({
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragDepth = useRef(0); // dragenter/leave 在子元素间反复触发,计数配对
|
||||
|
||||
// 锚点恢复的轮询校准:渲染后布局还会无事件地微调一次(实测 ~6px,RO 也
|
||||
// 抓不到这种再分配),对齐到位后是零修正的空转,用户接管即停。
|
||||
// 会话切换恢复与大纲跳转共用同一条路径——跳转别自己写 scrollIntoView,
|
||||
// 图片解码/字体加载会把它顶漂,这里已经处理过。
|
||||
const restoreTimer = useRef(0);
|
||||
const stopRestorePolling = () => {
|
||||
window.clearInterval(restoreTimer.current);
|
||||
restoreTimer.current = 0;
|
||||
};
|
||||
const startRestore = (anchor: number, offset: number) => {
|
||||
restoreRef.current = { anchor, offset };
|
||||
alignLog();
|
||||
stopRestorePolling();
|
||||
restoreTimer.current = window.setInterval(() => {
|
||||
if (restoreRef.current) alignLog();
|
||||
else stopRestorePolling();
|
||||
}, 200);
|
||||
};
|
||||
useEffect(() => stopRestorePolling, []);
|
||||
|
||||
// 「加载更早」的位置保持:前插会把所有条目往下推,记像素没用,记**元素**
|
||||
// ——keyBase 稳定 key 保证 React 不会把既有条目换成新节点,前插后按同一
|
||||
// 元素重新对齐,视口纹丝不动(云端那条路径至今没做,别照抄)
|
||||
const prependAnchor = useRef<{ node: Element; offset: number } | null>(null);
|
||||
const onLoadEarlier = () => {
|
||||
pinnedRef.current = false;
|
||||
void session.loadEarlier(() => {
|
||||
const el = logRef.current;
|
||||
const col = el?.firstElementChild;
|
||||
if (!el || !col) return;
|
||||
const elTop = el.getBoundingClientRect().top;
|
||||
for (const kid of Array.from(col.children)) {
|
||||
const r = kid.getBoundingClientRect();
|
||||
if (r.bottom > elTop) {
|
||||
prependAnchor.current = { node: kid, offset: elTop - r.top };
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
// 用 layout effect:DOM 已更新但尚未绘制,这一帧就把位置纠回去,不闪
|
||||
useLayoutEffect(() => {
|
||||
const pa = prependAnchor.current;
|
||||
if (!pa) return;
|
||||
prependAnchor.current = null;
|
||||
const col = logRef.current?.firstElementChild;
|
||||
const idx = col ? Array.prototype.indexOf.call(col.children, pa.node) : -1;
|
||||
if (idx >= 0) startRestore(idx, pa.offset);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat.items]);
|
||||
|
||||
// ==== 提问大纲 ====
|
||||
const outline = useMemo(() => outlineEntries(session.outline), [session.outline]);
|
||||
const [activeSeq, setActiveSeq] = useState<number | undefined>(undefined);
|
||||
const activeRaf = useRef(0);
|
||||
// 当前视口所在的提问 = 视口顶部之上最后一条用户气泡。判定沿用 saveAnchor
|
||||
// 同款 rect 比较,rAF 节流:流式期间每批帧都重算会把轨道刷成动画
|
||||
const updateActive = () => {
|
||||
const el = logRef.current;
|
||||
const col = el?.firstElementChild;
|
||||
if (!el || !col) return;
|
||||
const elTop = el.getBoundingClientRect().top;
|
||||
let seq: number | undefined;
|
||||
for (const kid of Array.from(col.children)) {
|
||||
if (kid.getBoundingClientRect().top - elTop > 8) break;
|
||||
const raw = (kid as HTMLElement).dataset?.mcSeq;
|
||||
if (raw) seq = Number(raw);
|
||||
}
|
||||
setActiveSeq((prev) => (prev === seq ? prev : seq));
|
||||
};
|
||||
const scheduleActive = () => {
|
||||
if (activeRaf.current) return;
|
||||
activeRaf.current = window.requestAnimationFrame(() => {
|
||||
activeRaf.current = 0;
|
||||
updateActive();
|
||||
});
|
||||
};
|
||||
useEffect(scheduleActive, [chat.items]);
|
||||
useEffect(() => () => window.cancelAnimationFrame(activeRaf.current), []);
|
||||
|
||||
/** 定位到某次提问;目标不在当前 DOM 里返回 false(还没加载进来) */
|
||||
const jumpToSeq = (seq: number): boolean => {
|
||||
const col = logRef.current?.firstElementChild;
|
||||
const node = col?.querySelector(`[data-mc-seq="${seq}"]`);
|
||||
if (!col || !node) return false;
|
||||
const idx = Array.prototype.indexOf.call(col.children, node);
|
||||
if (idx < 0) return false;
|
||||
pinnedRef.current = false;
|
||||
// 复用锚点恢复:图片解码/字体加载后仍会自动纠偏(scrollIntoView 不会)
|
||||
startRestore(idx, -12);
|
||||
node.classList.remove("mc-jump-flash");
|
||||
void (node as HTMLElement).offsetWidth; // 重启动画
|
||||
node.classList.add("mc-jump-flash");
|
||||
window.setTimeout(() => node.classList.remove("mc-jump-flash"), 1000);
|
||||
return true;
|
||||
};
|
||||
const jumpWithRetry = (seq: number, tries = 12) => {
|
||||
if (jumpToSeq(seq) || tries <= 0) return;
|
||||
window.setTimeout(() => jumpWithRetry(seq, tries - 1), 32);
|
||||
};
|
||||
const onOutlineJump = (e: OutlineEntry) => {
|
||||
if (jumpToSeq(e.seq)) return;
|
||||
// 更早的提问还没加载:按它那一轮的偏移把历史补齐,再定位
|
||||
void session.ensureLoaded(e.offset).then(() => jumpWithRetry(e.seq));
|
||||
};
|
||||
|
||||
// 会话切换:复位跟随状态并取出记忆位置。ChatView 不按会话重挂载,
|
||||
// 不显式复位的话 pinnedRef 会带着上一会话的值进入新会话(切过来停在顶部的根因)
|
||||
useLayoutEffect(() => {
|
||||
const saved = session.id ? scrollMemo.get(session.id) : undefined;
|
||||
pinnedRef.current = saved ? saved.pinned : true; // 首次打开默认贴底
|
||||
restoreRef.current = saved && !saved.pinned ? { anchor: saved.anchor, offset: saved.offset } : null;
|
||||
if (!restoreRef.current) return;
|
||||
// 渲染后布局还会无事件地微调一次(实测 ~6px,RO 也抓不到这种再分配):
|
||||
// 恢复期间低频轮询对齐兜底,对齐到位后是零修正的空转,用户接管即停
|
||||
const iv = window.setInterval(() => {
|
||||
if (restoreRef.current) alignLog();
|
||||
else clearInterval(iv);
|
||||
}, 200);
|
||||
return () => clearInterval(iv);
|
||||
if (saved && !saved.pinned) startRestore(saved.anchor, saved.offset);
|
||||
else restoreRef.current = null;
|
||||
return () => stopRestorePolling();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id]);
|
||||
|
||||
// 自动滚动:优先对齐记忆锚点,否则贴底跟随
|
||||
@@ -503,6 +608,7 @@ export function ChatView({
|
||||
// 卡在中途)。离底判定只认用户真实输入(onWheel/滚动条拖拽)
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 40) pinnedRef.current = true;
|
||||
saveAnchor();
|
||||
scheduleActive();
|
||||
// 滚动停止后布局仍会微调一次(实测 ~6px,不发 scroll 事件),停稳后补一次校准
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = window.setTimeout(saveAnchor, 600);
|
||||
@@ -709,7 +815,9 @@ export function ChatView({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// scrollbar-gutter 两侧对称预留(Chromium 94+):滚动条出现时内容列不再被挤得比 composer 偏左
|
||||
// 外层只为提问大纲提供定位上下文:轨道绝对定位贴在滚动视口左缘,
|
||||
// 不参与布局,窄窗口下正文列宽不受影响
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative", display: "flex" }}>
|
||||
<div
|
||||
ref={logRef}
|
||||
onScroll={onLogScroll}
|
||||
@@ -719,17 +827,40 @@ export function ChatView({
|
||||
style={{ flex: 1, overflowY: "auto", overflowX: "hidden", minHeight: 0, scrollbarGutter: "stable both-edges" }}
|
||||
>
|
||||
<div style={{ width: "100%", maxWidth: COL_MAX, margin: "0 auto", padding: "28px 30px 18px", display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
{session.canLoadEarlier && (
|
||||
<button
|
||||
className="hv"
|
||||
onClick={onLoadEarlier}
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
border: "1px solid var(--line)",
|
||||
background: "var(--card)",
|
||||
color: "var(--t3)",
|
||||
fontSize: 11.5,
|
||||
borderRadius: 8,
|
||||
padding: "4px 14px",
|
||||
cursor: "pointer",
|
||||
boxShadow: "var(--cardSh)",
|
||||
}}
|
||||
>
|
||||
{session.loadingEarlier ? "加载中…" : "加载更早的对话"}
|
||||
</button>
|
||||
)}
|
||||
<LogList
|
||||
items={chat.items}
|
||||
keyBase={chat.keyBase}
|
||||
onPermAnswer={session.answerPerm}
|
||||
onAskAnswer={session.answerAsk}
|
||||
onOpenChild={onOpenChild}
|
||||
uploadUrl={session.uploadUrl}
|
||||
onLocalLink={revealMarkdownLink}
|
||||
workdir={workdir}
|
||||
loadFullTool={session.loadFrame}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OutlineNav entries={outline} activeSeq={activeSeq} onJump={onOutlineJump} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==== 运行条 + 排队 + composer(680 列,钉在底部)====
|
||||
|
||||
@@ -7,6 +7,7 @@ export { CodeView } from "./codeView";
|
||||
export { DiffPanel } from "./diffView";
|
||||
export { MONO } from "./fonts";
|
||||
export { LogList } from "./logView";
|
||||
export { OutlineNav, outlineEntries, type OutlineEntry } from "./outline";
|
||||
export { Markdown } from "./markdown";
|
||||
export { TaskPanel } from "./taskPanel";
|
||||
export { ToolCard } from "./toolCard";
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// 折叠等价性:整套回放物化方案的地基。
|
||||
//
|
||||
// 壳侧把 events.jsonl 的流式碎片折叠成 replay.jsonl 才敢只回放窗口,前提是
|
||||
// **折叠不改变 UI 的归约结果**。这条断言就是那个前提:同一份素材,原始帧与
|
||||
// 折叠帧喂给 reduceBatch,得到的 ChatState 必须逐字段相同。
|
||||
//
|
||||
// 素材在 desktop/fixtures/replay/,folded.jsonl 由 Rust 侧真实折叠产出
|
||||
// (`cargo test regenerate_fold_fixture -- --ignored`),另有一条 Rust 用例
|
||||
// 钉住"折叠输出 == committed folded.jsonl"。于是两侧任一方改了规则而没同步,
|
||||
// 这里或那里必有一条红。
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// ?raw 静态导入而不是 node:fs:UI 包不引 @types/node,tsc --noEmit 是构建前置
|
||||
import foldedRaw from "../../fixtures/replay/folded.jsonl?raw";
|
||||
import rawRaw from "../../fixtures/replay/raw.jsonl?raw";
|
||||
|
||||
import { frameData } from "./codec";
|
||||
import { initialChat, reduceBatch } from "./reduce";
|
||||
import type { Frame } from "./types";
|
||||
|
||||
function fixture(text: string): { frames: Frame[]; bytes: number } {
|
||||
return {
|
||||
frames: text.split("\n").filter(Boolean).map((l) => JSON.parse(l) as Frame),
|
||||
bytes: new TextEncoder().encode(text).length,
|
||||
};
|
||||
}
|
||||
|
||||
describe("回放折叠是等价变换", () => {
|
||||
const raw = fixture(rawRaw);
|
||||
const folded = fixture(foldedRaw);
|
||||
|
||||
it("原始帧与折叠帧归约出完全相同的会话状态", () => {
|
||||
expect(reduceBatch(initialChat, folded.frames)).toEqual(reduceBatch(initialChat, raw.frames));
|
||||
});
|
||||
|
||||
it("素材确实覆盖了容易出错的形态", () => {
|
||||
const kinds = new Set(
|
||||
raw.frames.map((f) => {
|
||||
// 素材里旧格式(base64)与新格式(内联对象)混着,两种都要看得见
|
||||
const d = frameData<{ update?: { sessionUpdate?: string } }>(f);
|
||||
return d?.update?.sessionUpdate ?? f.type;
|
||||
}),
|
||||
);
|
||||
// 旧 base64 载荷、跨帧打断的流式段、覆盖语义帧、审批、提问卡、图片
|
||||
expect(raw.frames.some((f) => typeof f.data === "string")).toBe(true);
|
||||
expect(kinds).toContain("usage_update");
|
||||
expect(kinds).toContain("plan");
|
||||
expect(kinds).toContain("tool_call_update");
|
||||
expect(kinds).toContain("permission-req");
|
||||
expect(kinds).toContain("reply-question");
|
||||
});
|
||||
|
||||
it("折叠确实把帧数与体积压下来了(这才是它存在的理由)", () => {
|
||||
expect(folded.frames.length).toBeLessThan(raw.frames.length / 2);
|
||||
expect(folded.bytes).toBeLessThan(raw.bytes / 2);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { Markdown } from "./markdown";
|
||||
import { AskCard, PermCard } from "./promptCards";
|
||||
import { permAnchors } from "./reduce";
|
||||
import { ToolCard } from "./toolCard";
|
||||
import type { LogItem } from "./types";
|
||||
import type { Frame, LogItem } from "./types";
|
||||
import { UploadImg, downloadUpload } from "./uploadMedia";
|
||||
|
||||
/** 思考块:单行折叠(✦ 思考 + 摘要省略),点击在下方展开完整文本的缩进块。
|
||||
@@ -79,8 +79,9 @@ function ModelSwitchEvent({ names }: { names: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 用户消息里的附件行:`[图片]/[文件] <工作区相对路径>`(composer 发送时拼接的约定格式) */
|
||||
const ATT_LINE = /^\[(图片|文件)\] (\S+)$/;
|
||||
/** 用户消息里的附件行:`[图片]/[文件] <工作区相对路径>`(composer 发送时拼接的约定格式)。
|
||||
* 提问大纲复用同一约定剥附件行,故导出。 */
|
||||
export const ATT_LINE = /^\[(图片|文件)\] (\S+)$/;
|
||||
|
||||
/** 消息时间:默认隐藏,悬停消息时在其上沿浮出,不参与正文布局。 */
|
||||
function MessageTime({ timestamp, align }: { timestamp?: number; align: "start" | "end" }) {
|
||||
@@ -107,10 +108,13 @@ function MessageTime({ timestamp, align }: { timestamp?: number; align: "start"
|
||||
function UserBubble({
|
||||
text,
|
||||
timestamp,
|
||||
seq,
|
||||
uploadUrl,
|
||||
}: {
|
||||
text: string;
|
||||
timestamp?: number;
|
||||
/** 产生它的 user-input 帧 seq:提问大纲按它定位这条气泡 */
|
||||
seq?: number;
|
||||
uploadUrl?: (path: string) => Promise<string>;
|
||||
}) {
|
||||
const [zoom, setZoom] = useState<string | null>(null);
|
||||
@@ -125,7 +129,7 @@ function UserBubble({
|
||||
}
|
||||
const body = rest.join("\n").trim();
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<div data-mc-seq={seq} style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<div
|
||||
className="mc-message-row"
|
||||
style={{
|
||||
@@ -233,7 +237,9 @@ function ItemView({
|
||||
}) {
|
||||
switch (item.kind) {
|
||||
case "user":
|
||||
return <UserBubble text={item.text} timestamp={item.timestamp} uploadUrl={uploadUrl} />;
|
||||
return (
|
||||
<UserBubble text={item.text} timestamp={item.timestamp} seq={item.seq} uploadUrl={uploadUrl} />
|
||||
);
|
||||
case "agent":
|
||||
return (
|
||||
<div
|
||||
@@ -263,14 +269,19 @@ function ItemView({
|
||||
/** 对话流:相邻工具调用共享一个卡片外框,以细分割线保留逐项结构。 */
|
||||
export function LogList({
|
||||
items,
|
||||
keyBase = 0,
|
||||
onPermAnswer,
|
||||
onAskAnswer,
|
||||
onOpenChild,
|
||||
uploadUrl,
|
||||
onLocalLink,
|
||||
workdir,
|
||||
loadFullTool,
|
||||
}: {
|
||||
items: LogItem[];
|
||||
/** 渲染 key 的基准(见 ChatState.keyBase):「加载更早」前插 N 条时它减 N,
|
||||
* 既有条目的 key 保持不变——否则整列重挂载、展开态串位、markdown 全部重解析 */
|
||||
keyBase?: number;
|
||||
onPermAnswer: (id: string, action: "allow" | "always" | "persist" | "deny") => void;
|
||||
/** 回答 AI 提问卡(云端任务);缺省则提问卡只读 */
|
||||
onAskAnswer?: (askId: string, answers: Record<string, string | string[]>) => void;
|
||||
@@ -281,6 +292,8 @@ export function LogList({
|
||||
onLocalLink?: (path: string) => void;
|
||||
/** 工作区根:工具卡标题里的绝对路径按它收敛为相对路径 */
|
||||
workdir?: string;
|
||||
/** 回读被截断的工具大字段原文(见 fold.rs 的大字段护栏) */
|
||||
loadFullTool?: (seq: number) => Promise<Frame>;
|
||||
}) {
|
||||
// 审批锚定:待决 perm 带 toolCallId 且有同 id 工具卡时,按钮行嵌进
|
||||
// 那张卡(见 reduce.ts::permAnchors),对应的独立审批项跳过不渲染;
|
||||
@@ -301,7 +314,7 @@ export function LogList({
|
||||
names.push(name);
|
||||
i++;
|
||||
}
|
||||
out.push(<ModelSwitchEvent key={"models" + start} names={names} />);
|
||||
out.push(<ModelSwitchEvent key={"models" + (keyBase + start)} names={names} />);
|
||||
} else if (it.kind === "tool") {
|
||||
const start = i;
|
||||
const group: Extract<LogItem, { kind: "tool" }>[] = [];
|
||||
@@ -313,7 +326,7 @@ export function LogList({
|
||||
}
|
||||
const grouped = group.length > 1;
|
||||
out.push(
|
||||
<div className={grouped ? "card tool-stack" : undefined} key={"g" + start} style={{ display: "flex", flexDirection: "column", gap: grouped ? 0 : 8, maxWidth: "92%" }}>
|
||||
<div className={grouped ? "card tool-stack" : undefined} key={"g" + (keyBase + start)} style={{ display: "flex", flexDirection: "column", gap: grouped ? 0 : 8, maxWidth: "92%" }}>
|
||||
{group.map((t, j) => (
|
||||
<ToolCard
|
||||
key={t.tcId || j}
|
||||
@@ -324,6 +337,7 @@ export function LogList({
|
||||
workdir={workdir}
|
||||
perm={anchors.get(t.tcId)}
|
||||
onPermAnswer={onPermAnswer}
|
||||
loadFullTool={loadFullTool}
|
||||
grouped={grouped}
|
||||
/>
|
||||
))}
|
||||
@@ -336,7 +350,7 @@ export function LogList({
|
||||
}
|
||||
out.push(
|
||||
<ItemView
|
||||
key={i}
|
||||
key={keyBase + i}
|
||||
item={it}
|
||||
onPermAnswer={onPermAnswer}
|
||||
onAskAnswer={onAskAnswer}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// 提问大纲:条目文案规则(纯函数)与两态渲染。
|
||||
// 跳转本身是 chat.tsx 的滚动锚点逻辑,靠手动验收 + 既有锚点用例守。
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { OutlineNav, outlineEntries } from "./components";
|
||||
import type { OutlineItem } from "./useSession";
|
||||
|
||||
const item = (over: Partial<OutlineItem> = {}): OutlineItem => ({
|
||||
seq: 1,
|
||||
offset: 0,
|
||||
text: "帮我看下这个 panic",
|
||||
timestamp: Date.UTC(2026, 6, 25, 2, 24),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("outlineEntries", () => {
|
||||
it("剥掉附件行,只留正文", () => {
|
||||
const [e] = outlineEntries([item({ text: "看下这张图\n[图片] .monkeycode/uploads/a.png" })]);
|
||||
expect(e.label).toBe("看下这张图");
|
||||
});
|
||||
|
||||
it("纯附件消息回退成计数,不出现空条目", () => {
|
||||
const [e] = outlineEntries([
|
||||
item({ text: "[图片] .monkeycode/uploads/a.png\n[文件] .monkeycode/uploads/b.pdf" }),
|
||||
]);
|
||||
expect(e.label).toBe("📎 2 个附件");
|
||||
});
|
||||
|
||||
it("多行压平并截断,超长带省略号", () => {
|
||||
const [e] = outlineEntries([item({ text: "第一行\n第二行\n" + "长".repeat(80) })]);
|
||||
expect(e.label.startsWith("第一行 第二行 ")).toBe(true);
|
||||
expect(e.label.endsWith("…")).toBe(true);
|
||||
expect(e.label.length).toBe(61);
|
||||
});
|
||||
|
||||
it("保留 seq 与轮偏移(跳转与翻页都靠它们)", () => {
|
||||
const [e] = outlineEntries([item({ seq: 42, offset: 1024 })]);
|
||||
expect(e.seq).toBe(42);
|
||||
expect(e.offset).toBe(1024);
|
||||
});
|
||||
|
||||
it("缺时间戳时不渲染时间", () => {
|
||||
const [e] = outlineEntries([item({ timestamp: undefined })]);
|
||||
expect(e.time).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OutlineNav", () => {
|
||||
const entries = outlineEntries([
|
||||
item({ seq: 7, offset: 0, text: "第一问" }),
|
||||
item({ seq: 40, offset: 512, text: "第二问" }),
|
||||
]);
|
||||
|
||||
it("收起态是一列点,当前项带标记", () => {
|
||||
const html = renderToStaticMarkup(<OutlineNav entries={entries} activeSeq={40} onJump={() => {}} />);
|
||||
expect(html.match(/mc-outline-dot/g)?.length).toBe(2);
|
||||
expect(html).toContain('data-outline-current="true"');
|
||||
// 收起态不渲染浮窗
|
||||
expect(html).not.toContain("mc-outline-panel");
|
||||
expect(html).not.toContain("第一问");
|
||||
});
|
||||
|
||||
it("一条提问的会话不占轨道", () => {
|
||||
const one = outlineEntries([item({ seq: 7, text: "唯一一问" })]);
|
||||
expect(renderToStaticMarkup(<OutlineNav entries={one} onJump={() => {}} />)).toBe("");
|
||||
});
|
||||
|
||||
it("带 aria-label,便于键盘/读屏定位", () => {
|
||||
const html = renderToStaticMarkup(<OutlineNav entries={entries} onJump={() => {}} />);
|
||||
expect(html).toContain('aria-label="提问大纲"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// 提问大纲:正文左缘一列小点(收起态),鼠标浮上去整条展开成浮窗,点浮窗里
|
||||
// 的某一条就滚到那次提问。
|
||||
//
|
||||
// 数据来自壳的 session_outline —— **全量**,包含尚未加载进对话流的更早提问
|
||||
// (条目自带那一轮在 replay.jsonl 的字节偏移,点到时由调用方先补历史再定位)。
|
||||
// 点本身不响应点击:6px 的目标太小,误点代价是整屏跳走。
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ATT_LINE } from "./logView";
|
||||
import type { OutlineItem } from "./useSession";
|
||||
|
||||
export interface OutlineEntry {
|
||||
/** 与 LogItem.user.seq 对表,用于定位 DOM 与高亮当前项 */
|
||||
seq: number;
|
||||
/** 该轮在 replay.jsonl 的字节偏移(翻页锚点) */
|
||||
offset: number;
|
||||
label: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const MAX_LABEL = 60;
|
||||
|
||||
function hhmm(ts?: number): string {
|
||||
if (ts === undefined || !Number.isFinite(ts)) return "";
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 大纲条目文案:剥掉附件行(与用户气泡同一 ATT_LINE 约定)、压平空白、截断。
|
||||
* 纯附件消息没有正文,回退成附件计数,不能出现空条目。 */
|
||||
export function outlineEntries(items: OutlineItem[]): OutlineEntry[] {
|
||||
return items.map((it) => {
|
||||
const body: string[] = [];
|
||||
let atts = 0;
|
||||
for (const line of it.text.split("\n")) {
|
||||
if (ATT_LINE.test(line)) atts += 1;
|
||||
else body.push(line);
|
||||
}
|
||||
const text = body.join(" ").replace(/\s+/g, " ").trim();
|
||||
const label = text
|
||||
? text.length > MAX_LABEL
|
||||
? text.slice(0, MAX_LABEL) + "…"
|
||||
: text
|
||||
: atts > 0
|
||||
? `📎 ${atts} 个附件`
|
||||
: "(空消息)";
|
||||
return { seq: it.seq, offset: it.offset, label, time: hhmm(it.timestamp) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 收起 = 一列点;悬停 = 大纲浮窗。两态一个组件,`onJump` 交给调用方定位。 */
|
||||
export function OutlineNav({
|
||||
entries,
|
||||
activeSeq,
|
||||
onJump,
|
||||
}: {
|
||||
entries: OutlineEntry[];
|
||||
/** 当前视口所在的那次提问(收起态点加粗、展开态条目高亮) */
|
||||
activeSeq?: number;
|
||||
onJump: (entry: OutlineEntry) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef(0);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 当前项始终可见:点多到轨道装不下、或提问多到浮窗要内滚时,
|
||||
// 打开就已经停在"我现在在哪"上,不用自己找
|
||||
useEffect(() => {
|
||||
const box = open ? panelRef.current : railRef.current;
|
||||
const target = box?.querySelector<HTMLElement>('[data-outline-current="true"]');
|
||||
if (!box || !target) return;
|
||||
const top = target.offsetTop - box.clientHeight / 2 + target.offsetHeight / 2;
|
||||
box.scrollTop = Math.max(0, top);
|
||||
}, [open, activeSeq, entries.length]);
|
||||
|
||||
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||
|
||||
// 一条提问的会话不值得占一条轨道
|
||||
if (entries.length < 2) return null;
|
||||
|
||||
const enter = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
setOpen(true);
|
||||
};
|
||||
// 延时收起:点列很窄,指针从点移向浮窗的路上会短暂离开,立即收会闪
|
||||
const leave = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="提问大纲"
|
||||
onMouseEnter={enter}
|
||||
onMouseLeave={leave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 18,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
zIndex: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={railRef}
|
||||
aria-hidden="true"
|
||||
className="mc-outline-rail"
|
||||
style={{ opacity: open ? 0 : 1, pointerEvents: open ? "none" : undefined }}
|
||||
>
|
||||
{entries.map((e) => (
|
||||
<span
|
||||
key={e.seq}
|
||||
className="mc-outline-dot"
|
||||
data-outline-current={e.seq === activeSeq ? "true" : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{open && (
|
||||
<div ref={panelRef} className="pop mc-outline-panel">
|
||||
{entries.map((e) => (
|
||||
<button
|
||||
key={e.seq}
|
||||
className="hv menu-item"
|
||||
aria-current={e.seq === activeSeq ? "true" : undefined}
|
||||
data-outline-current={e.seq === activeSeq ? "true" : undefined}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onJump(e);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
padding: "6px 9px",
|
||||
gap: 8,
|
||||
color: e.seq === activeSeq ? "var(--accTx)" : "var(--t2)",
|
||||
fontWeight: e.seq === activeSeq ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span className="ellipsis" style={{ flex: 1, minWidth: 0, textAlign: "left" }}>
|
||||
{e.label}
|
||||
</span>
|
||||
{e.time && (
|
||||
<span style={{ flex: "none", fontSize: 10.5, color: "var(--t5)" }}>{e.time}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
// 回归用例见文末「旧格式帧兼容」。
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { b64encode } from "./codec";
|
||||
import { answerAsk, answerPerm, initialChat, permAnchors, permStateLabel, reduceBatch, reduceFrame } from "./reduce";
|
||||
import { answerAsk, answerPerm, initialChat, permAnchors, permStateLabel, prependBatch, reduceBatch, reduceFrame, type ChatState } from "./reduce";
|
||||
import type { AcpUpdate, Frame, LogItem, ToolProgress } from "./types";
|
||||
|
||||
const frame = (type: string, data?: unknown, kind?: string): Frame => ({
|
||||
@@ -621,3 +621,72 @@ describe("旧格式帧兼容(data = base64(JSON) 字符串)", () => {
|
||||
expect(s.items.at(-1)).toEqual({ kind: "sys", text: "✗ 裸串", error: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("prependBatch(加载更早)", () => {
|
||||
const f = (type: string, data?: unknown, kind?: string, seq?: number) =>
|
||||
({ type, ...(kind ? { kind } : {}), ...(data !== undefined ? { data } : {}), ...(seq !== undefined ? { seq } : {}) }) as Frame;
|
||||
|
||||
it("更早的一段只贡献 items,不把此刻的运行态/用量覆盖成历史值", () => {
|
||||
const now: ChatState = {
|
||||
...initialChat,
|
||||
items: [{ kind: "user", text: "现在这条" }],
|
||||
running: true,
|
||||
usage: { used: 999, size: 1000 },
|
||||
};
|
||||
const older = [
|
||||
f("task-started"),
|
||||
f("task-running", { update: { sessionUpdate: "usage_update", used: 1, size: 1000 } }, "acp_event"),
|
||||
f("task-ended"),
|
||||
];
|
||||
|
||||
const s = prependBatch(now, older);
|
||||
|
||||
expect(s.running).toBe(true);
|
||||
expect(s.usage).toEqual({ used: 999, size: 1000 });
|
||||
expect(s.items[s.items.length - 1]).toEqual({ kind: "user", text: "现在这条" });
|
||||
});
|
||||
|
||||
it("keyBase 左移与新增条数一致,既有条目的渲染 key 不变", () => {
|
||||
const now: ChatState = { ...initialChat, items: [{ kind: "user", text: "第二问" }] };
|
||||
const older = [f("user-input", { content: b64encode("第一问") }, undefined, 7), f("task-started"), f("task-ended")];
|
||||
|
||||
const s = prependBatch(now, older);
|
||||
|
||||
const added = s.items.length - now.items.length;
|
||||
expect(s.keyBase).toBe(now.keyBase - added);
|
||||
// 「第二问」原 key = 0 + 0;前插后 key = keyBase + 新下标,应当仍是 0
|
||||
expect(s.keyBase + s.items.indexOf(now.items[0])).toBe(0);
|
||||
});
|
||||
|
||||
it("空的一页不动状态", () => {
|
||||
const now: ChatState = { ...initialChat, items: [{ kind: "user", text: "只有这条" }] };
|
||||
expect(prependBatch(now, [])).toBe(now);
|
||||
});
|
||||
});
|
||||
|
||||
describe("大字段护栏标记透传", () => {
|
||||
it("_meta.mcSrc 原样带到工具项上(工具卡据此回读全文)", () => {
|
||||
// 壳侧物化时截断了 rawOutput,并在 _meta 留下按 seq 回读的凭据
|
||||
const s = reduceBatch(initialChat, [
|
||||
frame("task-running", { update: { sessionUpdate: "tool_call", toolCallId: "t1", title: "Read" } }, "acp_event"),
|
||||
frame(
|
||||
"task-running",
|
||||
{
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "t1",
|
||||
status: "completed",
|
||||
rawOutput: { output: "前 1KB…" },
|
||||
_meta: { mcSrc: { seq: 42 } },
|
||||
},
|
||||
},
|
||||
"acp_event",
|
||||
),
|
||||
]);
|
||||
|
||||
const tool = s.items.find((i) => i.kind === "tool") as Extract<LogItem, { kind: "tool" }>;
|
||||
expect((tool._meta as { mcSrc?: { seq?: number } }).mcSrc?.seq).toBe(42);
|
||||
// 行内头部照常渲染,卡片折叠态不受影响
|
||||
expect(tool.out).toContain("前 1KB");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,11 @@ export interface ChatState {
|
||||
model: string;
|
||||
/** 会话权限模式(permission_mode_update 帧回写;空 = 以会话 meta 为准) */
|
||||
permMode: string;
|
||||
/** 渲染 key 的基准:key = keyBase + 下标。"加载更早"往前插 N 条时减 N,
|
||||
* 既有条目的 key 因此保持不变——否则下标 key 整体平移,React 认不出
|
||||
* 同一条,ToolCard/ThoughtView 的展开态串位、整列重挂载(markdown 全部
|
||||
* 重解析)。 */
|
||||
keyBase: number;
|
||||
}
|
||||
|
||||
export const initialChat: ChatState = {
|
||||
@@ -29,6 +34,7 @@ export const initialChat: ChatState = {
|
||||
turnEnded: false,
|
||||
model: "",
|
||||
permMode: "",
|
||||
keyBase: 0,
|
||||
};
|
||||
|
||||
const PERM_OUTCOME: Record<PermOutcome, string> = {
|
||||
@@ -425,7 +431,13 @@ export function reduceFrame(s: ChatState, f: Frame): ChatState {
|
||||
} catch {
|
||||
text = data?.content ?? "";
|
||||
}
|
||||
return push(s, { kind: "user", text, ...(f.timestamp !== undefined ? { timestamp: f.timestamp } : {}) });
|
||||
return push(s, {
|
||||
kind: "user",
|
||||
text,
|
||||
...(f.timestamp !== undefined ? { timestamp: f.timestamp } : {}),
|
||||
// 大纲跳转的锚:壳的 session_outline 条目按同一 seq 对表
|
||||
...(f.seq !== undefined ? { seq: f.seq } : {}),
|
||||
});
|
||||
}
|
||||
case "permission-req": {
|
||||
const data = frameData<{ id?: string; title?: string; tool?: string; tool_call_id?: string }>(f);
|
||||
@@ -511,6 +523,21 @@ export function reduceBatch(s: ChatState, batch: Frame[]): ChatState {
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 「加载更早」:把更早的一段历史帧归约后插到最前。
|
||||
*
|
||||
* 更早那段单独归约(不是接着当前状态跑):它只贡献 items——running/usage/
|
||||
* plan 都是"此刻"的状态,让过去的帧回写会把现状覆盖成历史值。keyBase 同步
|
||||
* 左移,既有条目的渲染 key 不变。 */
|
||||
export function prependBatch(s: ChatState, batch: Frame[]): ChatState {
|
||||
const older = reduceBatch(initialChat, batch);
|
||||
if (older.items.length === 0) return s;
|
||||
return {
|
||||
...s,
|
||||
items: [...older.items, ...s.items],
|
||||
keyBase: s.keyBase - older.items.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** 待决审批 → 工具卡锚定(tcId → perm 项)。审批 UX 终态:perm 带
|
||||
* toolCallId 且流里存在同 id 的工具卡(引擎保证 tool_call 帧先于
|
||||
* permission-req 到达)时,审批按钮嵌进那张工具卡内部,独立审批大卡
|
||||
|
||||
@@ -49,21 +49,50 @@ export interface Conn {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** 一段历史:折叠后的帧 + 往前翻的游标(hasMore 为假即已到会话开头) */
|
||||
export interface HistoryPage {
|
||||
frames: Frame[];
|
||||
cursor: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface ConnHandlers {
|
||||
onFrames(batch: Frame[]): void;
|
||||
/** 打开会话拿到的尾部窗口(命令返回值,不走事件) */
|
||||
onHistory(page: HistoryPage): void;
|
||||
onStatus(text: string, connected: boolean): void;
|
||||
}
|
||||
|
||||
/** 往前翻页:cursor 之前的最多 limit 轮(形状与云端 mcTaskRounds 一致) */
|
||||
export const sessionHistory = (id: string, cursor: number, limit = 1) =>
|
||||
invoke<{ frames?: Frame[]; next_cursor?: number; has_more?: boolean }>("session_history", {
|
||||
id,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
|
||||
/** 回读单帧原文:物化时被截断的工具大字段,展开卡片时按 seq 取全文 */
|
||||
export const sessionFrame = (id: string, seq: number) =>
|
||||
invoke<Frame>("session_frame", { id, seq });
|
||||
|
||||
/** 提问大纲:全量 user-input 条目(content 为 base64,offset 是翻页锚点) */
|
||||
export const sessionOutline = (id: string) =>
|
||||
invoke<{ seq: number; offset: number; content: string; timestamp?: number }[]>("session_outline", {
|
||||
id,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开会话流:壳侧接引擎并按 ~30ms 批量推 frames:{sid} 事件(历史帧由
|
||||
* 引擎回放);断线由壳自动重连,状态经 conn-status:{sid} 事件透传。
|
||||
* 打开会话流:壳侧接引擎并按 ~30ms 批量推 frames:{sid} 事件;历史走
|
||||
* session_open 的**返回值**(尾部窗口,更早的按 cursor 翻)。断线由壳自动
|
||||
* 重连,状态经 conn-status:{sid} 事件透传。
|
||||
*/
|
||||
export function connect(sessionId: string, h: ConnHandlers): Conn {
|
||||
let closed = false;
|
||||
h.onStatus("连接中…", false);
|
||||
|
||||
// 监听注册落地后才 session_open:壳在命令内同步回放历史帧并推送连接
|
||||
// 状态,监听未注册前的事件会被丢(不排队),表现为空对话/卡在"连接中"
|
||||
// 监听注册落地后才 session_open:壳在命令内同步推送连接状态,监听未注册
|
||||
// 前的事件会被丢(不排队),表现为卡在"连接中"。历史帧自身已改走返回值,
|
||||
// 不再受这条时序约束——实时帧仍要靠它。
|
||||
const unFramesP = listenAsync(`frames:${sessionId}`, (p) => {
|
||||
if (!closed) h.onFrames(p as Frame[]);
|
||||
});
|
||||
@@ -73,7 +102,15 @@ export function connect(sessionId: string, h: ConnHandlers): Conn {
|
||||
h.onStatus(s.text, s.connected);
|
||||
});
|
||||
Promise.all([unFramesP, unStatusP])
|
||||
.then(() => invoke("session_open", { id: sessionId }))
|
||||
.then(() =>
|
||||
invoke<{ frames?: Frame[]; cursor?: number; has_more?: boolean } | null>("session_open", {
|
||||
id: sessionId,
|
||||
}),
|
||||
)
|
||||
.then((w) => {
|
||||
if (closed) return;
|
||||
h.onHistory({ frames: w?.frames ?? [], cursor: w?.cursor ?? 0, hasMore: !!w?.has_more });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!closed) h.onStatus("⚠ 打开会话失败: " + String(e), false);
|
||||
});
|
||||
|
||||
@@ -718,3 +718,50 @@ textarea::placeholder, input::placeholder { color: var(--t6); }
|
||||
.md img { max-width: 100%; height: auto; border-radius: 9px; }
|
||||
|
||||
select option { background: var(--pop); color: var(--t1); }
|
||||
|
||||
/* ==== 提问大纲(正文左缘的点列 / 悬停展开的浮窗)==== */
|
||||
.mc-outline-rail {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.mc-outline-rail::-webkit-scrollbar { width: 0; }
|
||||
.mc-outline-dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--t7);
|
||||
transition: background 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
.mc-outline-dot[data-outline-current="true"] {
|
||||
background: var(--acc);
|
||||
transform: scale(1.35);
|
||||
}
|
||||
nav:hover > .mc-outline-rail > .mc-outline-dot { background: var(--t5); }
|
||||
nav:hover > .mc-outline-rail > .mc-outline-dot[data-outline-current="true"] { background: var(--acc); }
|
||||
.mc-outline-panel {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 280px;
|
||||
max-height: calc(100% - 24px);
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
/* 跳转落点闪一下,否则在密集对话里找不到自己点的是哪条 */
|
||||
.mc-jump-flash > .mc-message-row {
|
||||
animation: mcflash 0.9s ease;
|
||||
}
|
||||
@keyframes mcflash {
|
||||
0%, 55% { box-shadow: 0 0 0 3px var(--accBg2); }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// 工具卡:状态点、动作/目标、耗时、子代理进度直播、结构化详情与内嵌审批。
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { DiffPanel } from "./diffView";
|
||||
import { MONO } from "./fonts";
|
||||
import { IconCheck, IconChevronRight } from "./icons";
|
||||
import { Markdown, MarkdownInline } from "./markdown";
|
||||
import { PermActions, type PermAnswerFn } from "./promptCards";
|
||||
import { toolDetailFor } from "./toolDetails";
|
||||
import { frameData } from "./codec";
|
||||
import { toolDetailFor, toolResultText } from "./toolDetails";
|
||||
import { presentToolCall, toolDisplayName, type ToolTargetKind } from "./toolLabels";
|
||||
import type { LogItem } from "./types";
|
||||
import type { AcpUpdate, Frame, LogItem } from "./types";
|
||||
import { UploadImg } from "./uploadMedia";
|
||||
|
||||
/** 状态标记:执行中空心绿点呼吸(设计稿 agents 的 dot),结束 ✓/✗——
|
||||
@@ -101,6 +102,7 @@ export function ToolCard({
|
||||
workdir,
|
||||
perm,
|
||||
onPermAnswer,
|
||||
loadFullTool,
|
||||
grouped = false,
|
||||
}: {
|
||||
item: Extract<LogItem, { kind: "tool" }>;
|
||||
@@ -114,19 +116,43 @@ export function ToolCard({
|
||||
* 独立审批大卡随之不渲染;已决后由调用方不再传入,卡片回归常态 */
|
||||
perm?: Extract<LogItem, { kind: "perm" }>;
|
||||
onPermAnswer?: PermAnswerFn;
|
||||
/** 回读被截断的工具大字段原文(壳侧物化时超 4KB 会截,见 fold.rs);
|
||||
* 不传则只展示行内的截断头部 */
|
||||
loadFullTool?: (seq: number) => Promise<Frame>;
|
||||
/** 相邻工具共享外框时只渲染内部区块,由父级提供卡片底座。 */
|
||||
grouped?: boolean;
|
||||
}) {
|
||||
const [zoom, setZoom] = useState<string | null>(null);
|
||||
const [showAgentResult, setShowAgentResult] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
// 大字段护栏的另一半:行内只有头部时,展开即按 seq 回读原帧补全。
|
||||
// 截断与回读必须成对存在——只截不读会把子代理的最终产出切掉半截。
|
||||
const srcSeq = (item._meta as { mcSrc?: { seq?: number } } | undefined)?.mcSrc?.seq;
|
||||
const [full, setFull] = useState<AcpUpdate | null>(null);
|
||||
const [fullErr, setFullErr] = useState("");
|
||||
const [loadingFull, setLoadingFull] = useState(false);
|
||||
const wantFull = showDetail || showAgentResult;
|
||||
useEffect(() => {
|
||||
if (!wantFull || srcSeq === undefined || full || loadingFull || fullErr || !loadFullTool) return;
|
||||
setLoadingFull(true);
|
||||
loadFullTool(srcSeq)
|
||||
.then((f) => {
|
||||
const u = frameData<{ update?: AcpUpdate }>(f)?.update;
|
||||
if (u) setFull(u);
|
||||
else setFullErr("原始记录已不可用");
|
||||
})
|
||||
.catch((e) => setFullErr(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoadingFull(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wantFull, srcSeq]);
|
||||
const feed = item.feed ?? [];
|
||||
// 子代理运行时卡内直播少量进度;完成后无论同步/后台都收成单行,
|
||||
// 完整过程与最终产出统一从子会话查看。
|
||||
const isAgentCard = !!(item.childSessionId || feed.length || item.background);
|
||||
const agentFinished = isAgentCard && item.status !== "run";
|
||||
const canOpenChild = !!(item.childSessionId && onOpenChild);
|
||||
const agentResult = agentFinished ? (item.result ?? "").trim() : "";
|
||||
const fullResult = full ? toolResultText(full.rawOutput, full.content) : "";
|
||||
const agentResult = agentFinished ? (fullResult || item.result || "").trim() : "";
|
||||
const visible = agentFinished ? [] : feed.slice(-FEED_WINDOW);
|
||||
// 极端情况下子会话入口缺失(云端只读流/旧 journal),保留按需展开兜底,
|
||||
// 但不再默认把整段结果灌进卡片。
|
||||
@@ -138,7 +164,16 @@ export function ToolCard({
|
||||
const target = presentation.targetKind === "path" ? stripWorkdir(fullTarget, workdir) : fullTarget;
|
||||
const { action, targetKind } = presentation;
|
||||
// 子代理沿用“子会话/查看结果”;其余本地与云端工具统一走结构化详情。
|
||||
const detail = !isAgentCard && item.status !== "run" ? toolDetailFor(item) : null;
|
||||
// 回读到全文后用全文渲染详情;没有护栏标记时 full 恒为 null,行为不变
|
||||
const shown = full
|
||||
? {
|
||||
...item,
|
||||
...(full.rawInput !== undefined ? { rawInput: full.rawInput } : {}),
|
||||
...(full.rawOutput !== undefined ? { rawOutput: full.rawOutput } : {}),
|
||||
...(full.content !== undefined ? { content: full.content } : {}),
|
||||
}
|
||||
: item;
|
||||
const detail = !isAgentCard && shown.status !== "run" ? toolDetailFor(shown) : null;
|
||||
const duration = formatToolDuration(item.durationMs);
|
||||
const stepRow: CSSProperties = {
|
||||
display: "flex",
|
||||
@@ -237,6 +272,11 @@ export function ToolCard({
|
||||
<Markdown text={summary} localImageUrl={uploadUrl} onLocalLink={onLocalLink} />
|
||||
</div>
|
||||
)}
|
||||
{wantFull && (loadingFull || fullErr) && (
|
||||
<div style={{ ...stepRow, display: "block", color: fullErr ? "var(--err)" : "var(--t5)" }}>
|
||||
{fullErr ? `⚠ 完整内容取不回来了: ${fullErr}` : "正在取完整内容…"}
|
||||
</div>
|
||||
)}
|
||||
{showDetail && detail && (
|
||||
<div
|
||||
aria-label="工具详情"
|
||||
|
||||
@@ -160,7 +160,15 @@ export interface AskQuestion {
|
||||
|
||||
/** 对话流里的一条渲染项 */
|
||||
export type LogItem =
|
||||
| { kind: "user"; text: string; /** 消息帧产生时间(Unix ms;旧记录可缺省) */ timestamp?: number }
|
||||
| {
|
||||
kind: "user";
|
||||
text: string;
|
||||
/** 消息帧产生时间(Unix ms;旧记录可缺省) */
|
||||
timestamp?: number;
|
||||
/** 产生它的 user-input 帧 seq:提问大纲 ↔ 渲染项 ↔ DOM 的稳定锚
|
||||
* (下标会因"加载更早"整体平移,seq 不会;旧记录可缺省) */
|
||||
seq?: number;
|
||||
}
|
||||
| { kind: "agent"; text: string; /** 首个流式分片时间(Unix ms;旧记录可缺省) */ timestamp?: number }
|
||||
| { kind: "thought"; text: string }
|
||||
| {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// 于是「监听先于命令」(ARCHITECTURE 契约 3)在这里是可断言的:谁把
|
||||
// invoke 提到 listen 前面,回放帧就静默丢失,对话流空 + trace 顺序翻转。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { b64decode } from "./codec";
|
||||
import { b64decode, b64encode } from "./codec";
|
||||
import { initialChat, type ChatState } from "./reduce";
|
||||
import { createSessionCore, type SessionCoreIO } from "./useSession";
|
||||
import type { Attachment, FileChange, Frame, LogItem } from "./types";
|
||||
@@ -22,8 +22,15 @@ let trace: string[] = [];
|
||||
/** 上行帧(session_send) */
|
||||
let sent: { ftype: string; payload: Record<string, unknown> }[] = [];
|
||||
let sendFail = false; // session_send 是否失败(壳侧发送失败窗口)
|
||||
/** session_open 时壳同步回放的历史帧 */
|
||||
/** session_open 返回的尾部窗口 */
|
||||
let replay: Frame[] = [];
|
||||
let replayCursor = 0;
|
||||
let replayHasMore = false;
|
||||
/** session_history 的应答队列(按调用顺序出队)与调用记录 */
|
||||
let historyPages: { frames: Frame[]; next_cursor?: number; has_more?: boolean }[] = [];
|
||||
let historyCalls: { cursor: number; limit: number }[] = [];
|
||||
/** session_outline 的应答 */
|
||||
let outlineScript: { seq: number; offset: number; content: string; timestamp?: number }[] = [];
|
||||
/** session_call 应答脚本:kind → 结果(未登记则 reject) */
|
||||
let callScript: Record<string, unknown> = {};
|
||||
let uploaded: string[] = []; // upload_file 收到的文件名(按序)
|
||||
@@ -57,6 +64,11 @@ beforeEach(() => {
|
||||
sent = [];
|
||||
sendFail = false;
|
||||
replay = [];
|
||||
replayCursor = 0;
|
||||
replayHasMore = false;
|
||||
historyPages = [];
|
||||
historyCalls = [];
|
||||
outlineScript = [];
|
||||
callScript = {};
|
||||
uploaded = [];
|
||||
uploadDeny = new Set();
|
||||
@@ -68,11 +80,18 @@ beforeEach(() => {
|
||||
trace.push("invoke:" + cmd);
|
||||
if (cmd === "session_open") {
|
||||
const sid = args!.id as string;
|
||||
// 壳在命令处理中同步回放历史帧并推连接状态(Tauri 事件不排队:
|
||||
// 监听没注册就永久丢失)
|
||||
if (replay.length) pushFrames(sid, replay);
|
||||
// 历史走返回值(尾部窗口);连接状态仍是命令内同步 emit 的事件
|
||||
// (Tauri 事件不排队:监听没注册就永久丢失)
|
||||
pushStatus(sid, "已连接", true);
|
||||
return Promise.resolve(null);
|
||||
return Promise.resolve({ frames: replay, cursor: replayCursor, has_more: replayHasMore });
|
||||
}
|
||||
if (cmd === "session_history") {
|
||||
historyCalls.push({ cursor: args!.cursor as number, limit: args!.limit as number });
|
||||
const page = historyPages.shift();
|
||||
return Promise.resolve(page ?? { frames: [], next_cursor: 0, has_more: false });
|
||||
}
|
||||
if (cmd === "session_outline") {
|
||||
return Promise.resolve(outlineScript);
|
||||
}
|
||||
if (cmd === "session_send") {
|
||||
if (sendFail) return Promise.reject(new Error("引擎未就绪"));
|
||||
@@ -152,6 +171,9 @@ function makeCore() {
|
||||
sessionsChanged: 0,
|
||||
remembered: null as string | null,
|
||||
forgotten: false,
|
||||
canLoadEarlier: false,
|
||||
loadingEarlier: [] as boolean[],
|
||||
outline: [] as { seq: number; offset: number; text: string; timestamp?: number }[],
|
||||
};
|
||||
const io: SessionCoreIO = {
|
||||
setId: (v) => (out.id = v),
|
||||
@@ -165,6 +187,9 @@ function makeCore() {
|
||||
setIsGitRepo: (v) => (out.isGitRepo = v),
|
||||
setChangesErr: (v) => (out.changesErr = v),
|
||||
notify: (text) => out.notices.push(text),
|
||||
setCanLoadEarlier: (v) => (out.canLoadEarlier = v),
|
||||
setLoadingEarlier: (v) => out.loadingEarlier.push(v),
|
||||
setOutline: (v) => (out.outline = v),
|
||||
onSessionsChanged: () => (out.sessionsChanged += 1),
|
||||
rememberSession: (sid) => (out.remembered = sid),
|
||||
forgetSession: () => (out.forgotten = true),
|
||||
@@ -522,3 +547,91 @@ describe("本地会话核心:文件抽屉查询", () => {
|
||||
expect((await core.reveal("a.ts")).result?.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("历史分页与提问大纲", () => {
|
||||
const userFrame = (text: string, seq: number): Frame =>
|
||||
({ type: "user-input", data: { content: b64encode(text) }, seq }) as unknown as Frame;
|
||||
|
||||
it("打开时的尾部窗口来自 session_open 返回值,并带出翻页能力与大纲", async () => {
|
||||
replay = [userFrame("窗口里的提问", 40), agentChunk("答")];
|
||||
replayCursor = 512;
|
||||
replayHasMore = true;
|
||||
outlineScript = [
|
||||
{ seq: 7, offset: 0, content: b64encode("很早的提问"), timestamp: 1 },
|
||||
{ seq: 40, offset: 512, content: b64encode("窗口里的提问"), timestamp: 2 },
|
||||
];
|
||||
const { core, out } = makeCore();
|
||||
await openAndSettle(core);
|
||||
await vi.waitFor(() => expect(out.outline.length).toBe(2));
|
||||
|
||||
expect(out.chat.items.map((i) => i.kind)).toEqual(["user", "agent"]);
|
||||
expect(out.canLoadEarlier).toBe(true);
|
||||
// 大纲是全量的:第一条尚未加载进对话流,但目录里有,且带翻页锚点
|
||||
expect(out.outline[0]).toEqual({ seq: 7, offset: 0, text: "很早的提问", timestamp: 1 });
|
||||
});
|
||||
|
||||
it("加载更早把历史插到最前,keyBase 左移保住既有条目的渲染 key", async () => {
|
||||
replay = [userFrame("第二问", 40)];
|
||||
replayCursor = 512;
|
||||
replayHasMore = true;
|
||||
historyPages = [{ frames: [userFrame("第一问", 7)], next_cursor: 0, has_more: false }];
|
||||
const { core, out } = makeCore();
|
||||
await openAndSettle(core);
|
||||
const keyOfSecond = out.chat.keyBase + 0;
|
||||
|
||||
await core.loadEarlier();
|
||||
|
||||
expect(historyCalls).toEqual([{ cursor: 512, limit: 1 }]);
|
||||
expect(out.chat.items.map((i) => (i as { text: string }).text)).toEqual(["第一问", "第二问"]);
|
||||
// 「第二问」原来在下标 0,前插一条后到下标 1;key = keyBase + 下标 应当不变
|
||||
expect(out.chat.keyBase + 1).toBe(keyOfSecond);
|
||||
expect(out.canLoadEarlier).toBe(false);
|
||||
// open 会先复位一次,翻页本身是 true → false
|
||||
expect(out.loadingEarlier.slice(-2)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("到头之后不再发请求,失败只提示不打断会话", async () => {
|
||||
replay = [];
|
||||
replayCursor = 0;
|
||||
replayHasMore = false;
|
||||
const { core, out } = makeCore();
|
||||
await openAndSettle(core);
|
||||
|
||||
await core.loadEarlier();
|
||||
|
||||
expect(historyCalls).toEqual([]);
|
||||
expect(out.notices).toEqual([]);
|
||||
});
|
||||
|
||||
it("跳到未加载的早期提问:按 offset 一路往前翻到覆盖它为止", async () => {
|
||||
replay = [userFrame("最新一问", 90)];
|
||||
replayCursor = 900;
|
||||
replayHasMore = true;
|
||||
historyPages = [
|
||||
{ frames: [userFrame("中间一问", 50)], next_cursor: 500, has_more: true },
|
||||
{ frames: [userFrame("最早一问", 7)], next_cursor: 0, has_more: false },
|
||||
];
|
||||
const { core, out } = makeCore();
|
||||
await openAndSettle(core);
|
||||
|
||||
await core.ensureLoaded(0);
|
||||
|
||||
expect(historyCalls.map((c) => c.cursor)).toEqual([900, 500]);
|
||||
expect(out.chat.items.map((i) => (i as { text: string }).text)).toEqual([
|
||||
"最早一问",
|
||||
"中间一问",
|
||||
"最新一问",
|
||||
]);
|
||||
});
|
||||
|
||||
it("本轮结束会重拉大纲(壳刚把这一轮物化)", async () => {
|
||||
const { core, out } = makeCore();
|
||||
await openAndSettle(core);
|
||||
outlineScript = [{ seq: 3, offset: 0, content: b64encode("刚问完的"), timestamp: 9 }];
|
||||
|
||||
pushFrames("s1", [frame("task-ended")]);
|
||||
await vi.waitFor(() => expect(out.outline.length).toBe(1));
|
||||
|
||||
expect(out.outline[0].text).toBe("刚问完的");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,29 @@
|
||||
// useSession —— React 侧:state 持有与镜像回写、notice 定时器、卸载断开,
|
||||
// 拼装为 SessionHandle(形状不变)。
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { connect, type Conn } from "./session";
|
||||
import { connect, sessionFrame, sessionHistory, sessionOutline, type Conn, type HistoryPage } from "./session";
|
||||
import { uploadFile, uploadFileURL } from "./uploads";
|
||||
import { b64encode } from "./codec";
|
||||
import { answerAsk as applyAskAnswer, answerPerm as applyPermAnswer, initialChat, reduceBatch, type ChatState } from "./reduce";
|
||||
import { b64decode, b64encode } from "./codec";
|
||||
import {
|
||||
answerAsk as applyAskAnswer,
|
||||
answerPerm as applyPermAnswer,
|
||||
initialChat,
|
||||
prependBatch,
|
||||
reduceBatch,
|
||||
type ChatState,
|
||||
} from "./reduce";
|
||||
import type { Attachment, FileChange, FileEntry, Frame, SessionNotice } from "./types";
|
||||
|
||||
/** 提问大纲的一条(壳的 session_outline 投影;text 已解 base64) */
|
||||
export interface OutlineItem {
|
||||
/** 产生它的 user-input 帧 seq:与 LogItem.user.seq 对表 */
|
||||
seq: number;
|
||||
/** 该轮在 replay.jsonl 的字节偏移:跳到未加载区间时当翻页 cursor */
|
||||
offset: number;
|
||||
text: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export type PermAction = "allow" | "always" | "persist" | "deny";
|
||||
|
||||
/** 「上次会话」记忆:读写统一在本模块(open 写入、close(forget) 清除、App 启动恢复时读)。 */
|
||||
@@ -72,6 +89,11 @@ export interface SessionCoreIO {
|
||||
notify(text: string, options?: Partial<Pick<SessionNotice, "tone" | "targetSessionId">>): void;
|
||||
/** 会话列表需要刷新(打开会话、本轮结束、切模型) */
|
||||
onSessionsChanged(): void;
|
||||
/** 还能不能往前翻(尾部窗口之前是否还有历史) */
|
||||
setCanLoadEarlier(v: boolean): void;
|
||||
setLoadingEarlier(v: boolean): void;
|
||||
/** 提问大纲(全量,含尚未加载进对话流的更早提问) */
|
||||
setOutline(items: OutlineItem[]): void;
|
||||
/** 「上次会话」记忆写入/清除(localStorage 留在 hook 侧,核心不碰浏览器全局) */
|
||||
rememberSession(id: string): void;
|
||||
forgetSession(): void;
|
||||
@@ -99,6 +121,10 @@ export function createSessionCore(io: SessionCoreIO, openConn: typeof connect =
|
||||
// 投递时机(否则每批帧都会重投,失败重试节奏与原实现不一致)
|
||||
let lastRunning = initialChat.running;
|
||||
let lastQueued: string | null = null;
|
||||
// 历史翻页游标:cursor 是当前已加载最早那一轮在 replay.jsonl 的字节偏移
|
||||
let cursor = 0;
|
||||
let hasMore = false;
|
||||
let loadingEarlier = false;
|
||||
|
||||
const setChat = (next: ChatState) => {
|
||||
chat = next;
|
||||
@@ -181,14 +207,82 @@ export function createSessionCore(io: SessionCoreIO, openConn: typeof connect =
|
||||
})();
|
||||
}
|
||||
|
||||
/** 大纲随历史推进变化:打开时拉一次,每轮结束再拉一次(几十 KB 的顺序读) */
|
||||
async function refreshOutline() {
|
||||
const id = sid;
|
||||
if (!id) return;
|
||||
try {
|
||||
const raw = await sessionOutline(id);
|
||||
if (sid !== id) return;
|
||||
io.setOutline(
|
||||
raw.map((e) => {
|
||||
let text = "";
|
||||
try {
|
||||
text = b64decode(e.content);
|
||||
} catch {
|
||||
/* 坏载荷按空条目处理,不吞掉整份大纲 */
|
||||
}
|
||||
return { seq: e.seq, offset: e.offset, text, timestamp: e.timestamp };
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* 大纲拿不到不影响会话本身 */
|
||||
}
|
||||
}
|
||||
|
||||
function applyHistory(page: HistoryPage) {
|
||||
// 已有内容时按前插处理:实时帧理论上可能先于命令返回值到达(两条都是
|
||||
// 异步 IPC),窗口与实时流在壳侧按 opened 切分、互不重叠,前插总是正确的
|
||||
setChat(chat.items.length === 0 ? reduceBatch(chat, page.frames) : prependBatch(chat, page.frames));
|
||||
cursor = page.cursor;
|
||||
hasMore = page.hasMore;
|
||||
io.setCanLoadEarlier(hasMore && cursor > 0);
|
||||
void refreshOutline();
|
||||
}
|
||||
|
||||
/** 往前翻一页;beforeApply 在写入 state 前同步回调,供视图记录滚动锚点 */
|
||||
async function loadEarlier(beforeApply?: () => void): Promise<void> {
|
||||
const id = sid;
|
||||
if (!id || !hasMore || cursor <= 0 || loadingEarlier) return;
|
||||
loadingEarlier = true;
|
||||
io.setLoadingEarlier(true);
|
||||
try {
|
||||
const r = await sessionHistory(id, cursor, 1);
|
||||
if (sid !== id) return;
|
||||
beforeApply?.();
|
||||
setChat(prependBatch(chat, r.frames ?? []));
|
||||
cursor = r.next_cursor ?? 0;
|
||||
hasMore = !!r.has_more;
|
||||
io.setCanLoadEarlier(hasMore && cursor > 0);
|
||||
} catch (e) {
|
||||
io.notify("⚠ 加载更早的对话失败: " + (e instanceof Error ? e.message : String(e)));
|
||||
} finally {
|
||||
loadingEarlier = false;
|
||||
io.setLoadingEarlier(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 确保 offset 所在的那一轮已加载(大纲跳到窗口之前的提问)。
|
||||
* 上限兜底,防坏 cursor 把这里变成死循环。 */
|
||||
async function ensureLoaded(offset: number): Promise<void> {
|
||||
for (let i = 0; i < 200 && hasMore && cursor > offset; i++) {
|
||||
const before = cursor;
|
||||
await loadEarlier();
|
||||
if (cursor === before) return; // 没前进(失败/到头),别空转
|
||||
}
|
||||
}
|
||||
|
||||
function handlers() {
|
||||
return {
|
||||
onHistory: applyHistory,
|
||||
onFrames: (batch: Frame[]) => {
|
||||
setChat(reduceBatch(chat, batch));
|
||||
// 本轮结束:刷新改动计数与会话列表
|
||||
if (chat.turnEnded) {
|
||||
setChat({ ...chat, turnEnded: false });
|
||||
void refreshChanges();
|
||||
// 轮末壳侧刚把这一轮物化,大纲多出一条提问
|
||||
void refreshOutline();
|
||||
io.onSessionsChanged();
|
||||
}
|
||||
flushQueued();
|
||||
@@ -222,6 +316,12 @@ export function createSessionCore(io: SessionCoreIO, openConn: typeof connect =
|
||||
pendingFiles = o.firstFiles?.length ? { sid: id, files: o.firstFiles } : null;
|
||||
lastRunning = false;
|
||||
lastQueued = null;
|
||||
cursor = 0;
|
||||
hasMore = false;
|
||||
loadingEarlier = false;
|
||||
io.setCanLoadEarlier(false);
|
||||
io.setLoadingEarlier(false);
|
||||
io.setOutline([]);
|
||||
io.rememberSession(id);
|
||||
conn = openConn(id, handlers());
|
||||
io.onSessionsChanged();
|
||||
@@ -246,6 +346,12 @@ export function createSessionCore(io: SessionCoreIO, openConn: typeof connect =
|
||||
io.setChangesErr("");
|
||||
lastRunning = false;
|
||||
lastQueued = null;
|
||||
cursor = 0;
|
||||
hasMore = false;
|
||||
loadingEarlier = false;
|
||||
io.setCanLoadEarlier(false);
|
||||
io.setLoadingEarlier(false);
|
||||
io.setOutline([]);
|
||||
if (forget) io.forgetSession();
|
||||
},
|
||||
|
||||
@@ -367,6 +473,14 @@ export function createSessionCore(io: SessionCoreIO, openConn: typeof connect =
|
||||
},
|
||||
|
||||
refreshChanges,
|
||||
loadEarlier,
|
||||
ensureLoaded,
|
||||
|
||||
/** 回读被截断的工具大字段原文(见 fold.rs 的大字段护栏) */
|
||||
loadFrame(seq: number) {
|
||||
if (!sid) return Promise.reject(new Error("未打开会话"));
|
||||
return sessionFrame(sid, seq);
|
||||
},
|
||||
|
||||
fileDiff(path: string) {
|
||||
const c = conn;
|
||||
@@ -423,6 +537,17 @@ export interface SessionHandle {
|
||||
/** null = 尚未探测;false 时文件抽屉不展示“改动”页。 */
|
||||
isGitRepo: boolean | null;
|
||||
changesErr: string;
|
||||
/** 尾部窗口之前还有历史(可「加载更早」) */
|
||||
canLoadEarlier: boolean;
|
||||
loadingEarlier: boolean;
|
||||
/** 往前翻一页;beforeApply 在写入 state 前同步回调,供视图记录滚动锚点 */
|
||||
loadEarlier(beforeApply?: () => void): Promise<void>;
|
||||
/** 确保某轮(replay.jsonl 字节偏移)已加载进对话流——大纲跳到更早提问用 */
|
||||
ensureLoaded(offset: number): Promise<void>;
|
||||
/** 提问大纲(全量,含尚未加载进对话流的更早提问) */
|
||||
outline: OutlineItem[];
|
||||
/** 回读被截断的工具大字段原文(工具卡展开时按需取) */
|
||||
loadFrame(seq: number): Promise<Frame>;
|
||||
/** 已上传附件/工作区图片的回读 URL(无会话时 undefined) */
|
||||
uploadUrl?: (path: string) => Promise<string>;
|
||||
|
||||
@@ -486,6 +611,9 @@ export function useSession(opts: { onSessionsChanged?: () => void } = {}): Sessi
|
||||
const [changes, setChanges] = useState<FileChange[] | null>(null);
|
||||
const [isGitRepo, setIsGitRepo] = useState<boolean | null>(null);
|
||||
const [changesErr, setChangesErr] = useState("");
|
||||
const [canLoadEarlier, setCanLoadEarlier] = useState(false);
|
||||
const [loadingEarlier, setLoadingEarlier] = useState(false);
|
||||
const [outline, setOutline] = useState<OutlineItem[]>([]);
|
||||
|
||||
// 回调经 ref 转发,避免调用方每次渲染的新函数搅动核心持有的 IO
|
||||
const onSessionsChangedRef = useRef(opts.onSessionsChanged);
|
||||
@@ -507,6 +635,9 @@ export function useSession(opts: { onSessionsChanged?: () => void } = {}): Sessi
|
||||
setIsGitRepo,
|
||||
setChangesErr,
|
||||
notify: pushNotice,
|
||||
setCanLoadEarlier,
|
||||
setLoadingEarlier,
|
||||
setOutline,
|
||||
onSessionsChanged: () => onSessionsChangedRef.current?.(),
|
||||
rememberSession: (sid) => localStorage.setItem(LAST_SESSION_KEY, sid),
|
||||
forgetSession: () => localStorage.removeItem(LAST_SESSION_KEY),
|
||||
@@ -534,6 +665,12 @@ export function useSession(opts: { onSessionsChanged?: () => void } = {}): Sessi
|
||||
changes,
|
||||
isGitRepo,
|
||||
changesErr,
|
||||
canLoadEarlier,
|
||||
loadingEarlier,
|
||||
loadEarlier: core.loadEarlier,
|
||||
ensureLoaded: core.ensureLoaded,
|
||||
outline,
|
||||
loadFrame: core.loadFrame,
|
||||
uploadUrl: id ? (p: string) => uploadFileURL(id, p) : undefined,
|
||||
open: core.open,
|
||||
close: core.close,
|
||||
|
||||
Reference in New Issue
Block a user