magic-agent 1.0.3 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -0,0 +1,1561 @@
1
+ /* ===== 运行态注册表(2026-09-18 重构)=====
2
+ 用户报障:「任务列表点击显示回答生成中 不能切换 为什么呢?肯定可以切换另1个会话去啊」。
3
+ 根因:流式是**增量写 DOM**(qaRenderAnswer / appendText / qaRenderDone 都往画布里写),
4
+ 而画布只有一份 #qa-stream —— 生成中一切换,这一轮的增量就会写进**另一个会话**的画布,
5
+ 所以老代码只能在切换处硬拦(`if(qaRunning) toast("回答生成中,稍候再切换")`)。
6
+ 现在改成「**画布跟着会话走**」:
7
+ · 一次 run = 一个条目,带自己的渲染句柄 h / 工具卡表 / 委派卡表 / 耗时 / 后台容器 holder;
8
+ · 切走 = 把这一轮的画布**整份摘到 holder**(节点不销毁,后台增量继续写进它们),
9
+ 切回来 = 再挂回 #qa-stream(内容比快照新,接得上正在跑的那一轮);
10
+ · 事件按 rid 路由到对应 run(不再有「全局 qaRid + 全局 _cur」那套单例)。
11
+ ⚠️ 但「同时只跑一轮」这条**没有变**(用户 2026-09-18 定稿「对话中不允许上一条没结束
12
+ 直接输入下1个」+ 主进程 `desk:ask` 是单会话槽:新请求会先杀掉旧的)。
13
+ 所以撰写区仍然全局锁死,只是**切换与查看不再被拦**。 */
14
+ const qaRuns = new Map(); // rid → run
15
+ function qaRunOfTask(id){ for(const r of qaRuns.values()) if(r.taskId === id) return r; return null; }
16
+ function qaTaskRunning(id){ return !!qaRunOfTask(id); }
17
+ /** 这一轮该往哪儿落笔:正常 = 可见画布 #qa-stream;被切到后台 = 自己的 holder */
18
+ function qaRunHost(run){ return (run && run.holder) ? run.holder : $("#qa-stream"); }
19
+ /** 摘掉画布里的「生成中」胶囊行(`.qa-run`)—— 一轮跑完 / 被停 / 快照恢复时都要摘。
20
+ * 为什么必须显式摘:`.qa-run` 是**行**不是状态,它不会自己消失。收尾路径漏一次,
21
+ * 这枚写着「生成中」的胶囊就永远钉在画布上,下一次发送再长出一枚 →
22
+ * 用户看到的「两个生成中」(2026-09-18 报障)。收尾统一走 qaRunEnd 收口。 */
23
+ function qaDropRunPills(host){
24
+ const s = host || $("#qa-stream"); if(!s || !s.querySelectorAll) return;
25
+ s.querySelectorAll(".qa-run").forEach(el=>el.remove());
26
+ }
27
+ /** 一轮收尾(成功 / 失败 / 被停)的统一收口:关掉流式光标 + 摘掉「生成中」胶囊。
28
+ * ⚠️ 六个收尾点(end / turn_end / error / 调用失败 ×2 / 点停止)以前各写一遍
29
+ * `h.finish(); h.run.remove()`,**点停止那条漏写** → 胶囊留在画布上(见上)。
30
+ * 现在只留这一个入口,漏不掉。入参是 qaRenderAnswer 返回的渲染句柄 `h`。 */
31
+ function qaRunEnd(h){
32
+ if(!h) return;
33
+ if(typeof h.finish === "function") h.finish(); // 尾部 pending 落盘 + 收掉 ▍光标
34
+ if(h.run && h.run.parentNode) h.run.remove(); // 摘掉这一轮的「生成中」胶囊行
35
+ }
36
+ /* 正在为「后台 run」写画布(它不在屏幕上)→ 别去滚动可见画布:
37
+ 否则前台那条会话会被后台的增量不停拽到底部(用户会看到「我这条怎么自己跳」)。 */
38
+ let qaPaintBg = false;
39
+ function qaSetChatMode(on){
40
+ const v = $("#view-chat"); if(v) v.classList.toggle("chat-mode", !!on);
41
+ }
42
+ function qaHasContent(){
43
+ const s = $("#qa-stream");
44
+ /* `.qa-hist-note`(2026-09-22)也算「有内容」:历史找不回来时画布上那条**说明**
45
+ 是这条对话唯一的落脚点 —— 不算它就会退回「描述你要做的事」那种新任务引导,
46
+ 看起来像「历史被清空了、这是一条新对话」(用户报障的观感就是这么来的)。 */
47
+ return !!(s && !s.hidden && s.querySelector(".qa-msg, .qa-hist-note"));
48
+ }
49
+ function qaSyncChatMode(){ qaSetChatMode(qaHasContent()); qaDotsSync(); }
50
+ /* 用户**离开底部**(点圆点跳转 / 自己滚上去看历史)→ 后续流式增量不再把他拽回底部。
51
+ ⚠️ 2026-10-20 用户:「滚动不了」—— 旧口径只认圆点跳转,手动上滚仍被每条增量拽回底部,
52
+ 长答复流式期间根本没法看上面。现在以「是否贴近底部」为准(见 qaDotsBind 的 scroll 监听)。
53
+ 回到底部 / 发新消息即复位。 */
54
+ let qaUserAway = false;
55
+ function qaNearBottom(s){ return !!s && (s.scrollHeight - s.scrollTop - s.clientHeight) < 24; }
56
+ function qaScrollIntoView(){
57
+ if(qaPaintBg) return; // 后台 run 的增量不许拽动前台画布(见 qaPaintBg)
58
+ if(qaUserAway) return; // 用户跳上去看历史了,别抢他的滚动位置
59
+ const s = $("#qa-stream"); if(!s) return;
60
+ s.scrollTop = s.scrollHeight; // 卡片固定撑满、自身即滚动容器(滚动条已隐藏),永远贴底
61
+ }
62
+ /** 切会话时把「用户离开底部」复位(2026-10-20):`qaUserAway` 是**画布级**状态,而画布只有一份。
63
+ * 在 A 里滚上去看历史,再切到 B,若不复位,B 打开时就停在顶部(看的是旧内容中间),
64
+ * 而不是它最新的那条消息。宿主切换会话时调用(见 qaTaskSwitch)。 */
65
+ function qaScrollReset(){ qaUserAway = false; }
66
+
67
+ /* ---------- 快速回话跳转(2026-10-12 用户:「增加一个快速跳转回话的功能 用户1个问题1个圆点」)----------
68
+ 对话流左侧一条竖排圆点轨:**一个用户提问一枚圆点** —— 数的就是流里那些 `.qa-msg.qa-mine`
69
+ (自己说的话),引擎回答 / 机器侧 trace / 收尾行都不算,所以圆点数 = 你问过几轮。
70
+ 点一枚 → 平滑滚到那一轮(那一轮的用户气泡贴到画布顶),并把它点亮;滚动时高亮跟着走。
71
+ ⚠️ 圆点里存的是**消息节点引用**:快照恢复(`innerHTML = snapshot`)会把节点整份换掉,
72
+ 所以刷新时按节点身份比对,换了就整体重建(见 qaDotsSync)。
73
+ ⚠️ 只有 1 轮提问不显示(一条圆点没有跳转的意义)。
74
+ ⚠️ 最多画 10 个标记(2026-10-16 用户:「原点超过10个之后开始省略中间几个 按照最多保留10个
75
+ 显示的方式控制」):
76
+ · ≤10 轮:一轮一枚;
77
+ · 11–19 轮:首 5 + 一根省略线 + 末 5(省略线只是占位,不可点);
78
+ · ≥20 轮:等距抽 10 枚(首尾必取,每枚都指向真实轮号)。 */
79
+ const QA_DOTS_MIN = 2;
80
+ const QA_DOTS_MAX = 10;
81
+ const QA_DOTS_STRIDE_FROM = 20;
82
+ /** 选哪些轮画圆点(0 基真实轮号)+ 省略线的插入位(-1 = 没有省略线)。 */
83
+ function qaDotsPick(n){
84
+ if(n <= QA_DOTS_MAX) return { picks: Array.from({ length: n }, (_, i)=> i), gap: -1 };
85
+ if(n < QA_DOTS_STRIDE_FROM){
86
+ const half = QA_DOTS_MAX / 2, picks = [];
87
+ for(let i = 0; i < half; i++) picks.push(i);
88
+ for(let i = n - half; i < n; i++) picks.push(i);
89
+ return { picks, gap: half }; // 省略线插在首段之后
90
+ }
91
+ const picks = [];
92
+ for(let i = 0; i < QA_DOTS_MAX; i++) picks.push(Math.round(i * (n - 1) / (QA_DOTS_MAX - 1)));
93
+ return { picks, gap: -1 };
94
+ }
95
+ function qaDotsSync(){
96
+ const rail = $("#qa-dots"), s = $("#qa-stream");
97
+ if(!rail || !s) return;
98
+ const mines = [...s.querySelectorAll(".qa-msg.qa-mine")];
99
+ if(mines.length < QA_DOTS_MIN){
100
+ rail.hidden = true; rail.innerHTML = "";
101
+ rail.__dots = null; rail.__gapEl = null; rail.__gapRange = null; rail.__mines = null;
102
+ return;
103
+ }
104
+ const { picks, gap } = qaDotsPick(mines.length);
105
+ const dots = rail.__dots;
106
+ /* 抽到的轮号、节点身份、省略位都一致 → 不重建(重建会让 hover / 焦点在流式期间不停丢) */
107
+ const same = dots && dots.length === picks.length && rail.__gapAt === gap
108
+ && dots.every((d, k)=> d.idx === picks[k] && d.msg === mines[picks[k]]);
109
+ if(!same){
110
+ rail.innerHTML = "";
111
+ rail.__mines = mines;
112
+ rail.__gapAt = gap;
113
+ rail.__dots = [];
114
+ rail.__gapEl = null;
115
+ rail.__gapRange = null;
116
+ picks.forEach((idx, k)=>{
117
+ if(k === gap){
118
+ const from = picks[k - 1] + 1, to = picks[k] - 1;
119
+ const g = document.createElement("span");
120
+ g.className = "qa-dotgap";
121
+ g.title = "中间省略 " + (to - from + 1) + " 轮(第 " + (from + 1) + "–" + (to + 1) + " 轮)";
122
+ rail.appendChild(g);
123
+ rail.__gapEl = g;
124
+ rail.__gapRange = [from, to];
125
+ }
126
+ const msg = mines[idx];
127
+ const b = document.createElement("button");
128
+ b.type = "button";
129
+ b.className = "qa-dot";
130
+ const t = (msg.querySelector(".qa-t") || {}).textContent || "";
131
+ b.title = "第 " + (idx + 1) + " 轮 · " + (t.replace(/\s+/g, " ").trim().slice(0, 60) || "(无正文)");
132
+ b.setAttribute("aria-label", b.title);
133
+ b.onclick = ()=> qaDotsJump(idx);
134
+ rail.appendChild(b);
135
+ rail.__dots.push({ msg, el: b, idx });
136
+ });
137
+ }
138
+ rail.hidden = false;
139
+ qaDotsMark();
140
+ }
141
+ /** 点亮「当前那一轮」,优先级从高到低:
142
+ * ① `rail.__pin` —— 用户**刚点过**的那一轮(他明确选了这个);手动滚动(滚轮 / 触摸)时清掉;
143
+ * ② 贴在底部 → 最新那一轮(在底部看的就是最新内容;末轮很短时靠它兜住,
144
+ * 否则「顶线那一轮」会判成上一轮);
145
+ * ③ 否则 → 画布顶部落在哪一轮里(该轮气泡已滚过顶线、下一轮还没到)。 */
146
+ function qaDotsMark(){
147
+ const rail = $("#qa-dots"), s = $("#qa-stream");
148
+ if(!rail || !s || rail.hidden) return;
149
+ const dots = rail.__dots || [], mines = rail.__mines || [];
150
+ if(!dots.length || !mines.length) return;
151
+ /* cur 恒为**真实轮号**(0 基):圆点可能只是抽样出来的,不能拿圆点下标当轮号。 */
152
+ let cur = 0;
153
+ if(rail.__pin != null && rail.__pin < mines.length){
154
+ cur = rail.__pin;
155
+ }else if(qaNearBottom(s)){
156
+ cur = mines.length - 1;
157
+ }else{
158
+ const top = s.getBoundingClientRect().top;
159
+ mines.forEach((msg, i)=>{ if(msg.isConnected && msg.getBoundingClientRect().top - top <= 20) cur = i; });
160
+ }
161
+ /* 当前轮落在省略段里 → 只点亮那根省略线,不硬点某一枚圆点 */
162
+ const inGap = !!(rail.__gapEl && rail.__gapRange && cur >= rail.__gapRange[0] && cur <= rail.__gapRange[1]);
163
+ if(rail.__gapEl) rail.__gapEl.classList.toggle("on", inGap);
164
+ if(inGap){
165
+ dots.forEach(d=> d.el.classList.remove("on"));
166
+ return;
167
+ }
168
+ /* 抽样轮(≥20 轮)里当前轮可能没有圆点 → 点亮它之前最近的那一枚 */
169
+ let lit = dots.find(d=> d.idx === cur);
170
+ if(!lit) for(const d of dots){ if(d.idx <= cur) lit = d; }
171
+ dots.forEach(d=> d.el.classList.toggle("on", d === lit));
172
+ }
173
+ /** 跳到第 i 轮(0 基):**瞬时**滚到那一轮的用户气泡贴顶,并闪一下。
174
+ * ⚠️ 不用平滑滚动(2026-10-12 用户:「点击原点 现在跳转太慢」):平滑滚动在长会话里要滚好几屏,
175
+ * 距离越远动画越久 —— 点一枚圆点却要等近一秒才到位。这里直接 `behavior:"instant"` 落位。
176
+ * ⚠️ 落位后给那一轮**闪一下**(见 qaDotsFlash):跳过去是瞬移,没有过程可看,
177
+ * 得给一个「到你眼前了」的落点反馈(与左栏需求跳转那枚 `tk-flash` 同一套语言)。
178
+ * 置 `qaUserAway` —— 这一跳是**用户的意图**,不该被后续流式增量一秒拽回底部。 */
179
+ function qaDotsJump(i){
180
+ const rail = $("#qa-dots"), s = $("#qa-stream");
181
+ const msg = rail && rail.__mines && rail.__mines[i];
182
+ if(!msg || !s || !msg.isConnected) return;
183
+ qaUserAway = true;
184
+ rail.__pin = i; // 这一枚点住:滚不到位(已是底/内容太短)也照他点的亮
185
+ const delta = msg.getBoundingClientRect().top - s.getBoundingClientRect().top;
186
+ s.scrollTo({ top: Math.max(0, s.scrollTop + delta - 10), behavior: "instant" });
187
+ qaDotsFlash(msg);
188
+ qaDotsMark();
189
+ }
190
+ /** 让跳过去的那一轮闪一下(1.2s 自动褪去)。连点同一枚也要重播:先摘类、强制回流、再加回去。 */
191
+ let qaDotsFlashTimer = 0;
192
+ function qaDotsFlash(msg){
193
+ if(!msg) return;
194
+ msg.classList.remove("qa-jump");
195
+ void msg.offsetWidth; // 强制回流(不读宽度的话浏览器会合并掉这次类变更)
196
+ msg.classList.add("qa-jump");
197
+ if(qaDotsFlashTimer) clearTimeout(qaDotsFlashTimer);
198
+ qaDotsFlashTimer = setTimeout(()=>{ if(msg.isConnected) msg.classList.remove("qa-jump"); }, 1300);
199
+ }
200
+ /** 只绑一次:画布滚动(更新高亮 + 回到底部时复位 qaUserAway)+ 流内容变化(增删圆点)。
201
+ * ⚠️ MutationObserver 只盯 childList(不盯 subtree):圆点数只跟顶层消息有关,
202
+ * 正文/工具卡内部怎么长都不影响 —— 少一层回调,流式期间也不至于每笔都刷。 */
203
+ function qaDotsBind(){
204
+ const s = $("#qa-stream");
205
+ if(!s || s.dataset.dotsBound === "1") return;
206
+ s.dataset.dotsBound = "1";
207
+ let raf = 0;
208
+ const kick = ()=>{ if(raf) return; raf = requestAnimationFrame(()=>{ raf = 0; qaDotsSync(); }); };
209
+ s.addEventListener("scroll", ()=>{
210
+ /* 贴近底部 → 恢复「贴底」跟随;离开底部(手动上滚、点圆点、翻历史)→ 停止跟随,
211
+ 否则流式每个增量都把用户拽回底部(2026-10-20「滚动不了」)。 */
212
+ qaUserAway = !qaNearBottom(s);
213
+ qaDotsMark();
214
+ }, { passive: true });
215
+ /* 真人滚动手势 → 放掉「刚点过的那一轮」,高亮重新跟着视口走
216
+ (只认滚轮 / 触摸:平滑滚动的 scroll 事件不算,否则跳转自己就把 pin 冲掉了)。
217
+ ⚠️ 上滚手势在这里**立刻**置 `qaUserAway`(2026-10-20):scroll 事件要到下一帧才来,
218
+ 流式增量却可能在这之前就贴底 —— 只靠 scroll 监听仍有「刚滚上去又被拽回」的竞态。 */
219
+ const unpin = ()=>{ const r = $("#qa-dots"); if(r) r.__pin = null; };
220
+ s.addEventListener("wheel", (ev)=>{
221
+ if(ev && ev.deltaY < 0 && !qaNearBottom(s)) qaUserAway = true;
222
+ unpin(); qaDotsMark();
223
+ }, { passive: true });
224
+ s.addEventListener("touchstart", ()=>{ unpin(); qaDotsMark(); }, { passive: true });
225
+ new MutationObserver(kick).observe(s, { childList: true });
226
+ window.addEventListener("resize", kick);
227
+ qaDotsSync();
228
+ }
229
+ /* ── 头像的两个**可覆盖默认值**(2026-10-17)────────────────────────────
230
+ 它们是「内核有默认、宿主可在加载后覆盖」的那种符号,**不是**必须实现品:
231
+ · QA_BOT_AVATAR_SRC —— bot 头像的图片来源。默认沿用 magic-test 工作台那张应用图标;
232
+ 宿主在自己的适配层里赋空串 = 「我没有这张图」→ 改用中性机器人头(见 qaBotAvatarHtml)。
233
+ · QA_USER_INITIAL —— 用户头像那个字。默认空 → 取 `#nu-name` 首字,取不到用「我」。
234
+ ⚠️ 为什么写成 `var`:这样装配契约(tests/check-conv-shards.mjs)看得见它们由内核声明,
235
+ 不会把它们当成「宿主必须提供」的符号 —— 但**默认值仍可在下游被覆盖**(经典脚本,
236
+ 顶层 var 就是 window 上的属性,宿主适配层晚于内核加载,赋一次就生效)。 */
237
+ var QA_BOT_AVATAR_SRC = 'assets/icon.png';
238
+ var QA_USER_INITIAL = '';
239
+
240
+ /** 对话头像(用户 2026-09-17:「用户因为没用头像应该显示一个1个超,bot这边应该用现在的
241
+ * 应用图标大喵标识」):
242
+ * · 用户侧没头像 → 显示 1 个字 = 左下角显示名的首字(「超哥」→「超」,取不到就「超」);
243
+ * · bot 侧 → 当前应用图标 assets/icon.png(大喵);
244
+ * · **审核用户**(2026-09-20 加)→ 一枚「审」字头像(琥珀底),与作者、引擎都区分开 ——
245
+ * 它代表的是「审查轮的结论说话人」,不是需求作者本人(用户原话:
246
+ * 「验证不通过的返回建议 再代表审核用户发送到对话中」)。
247
+ * 实时渲染(qaRenderMine / qaRenderAnswer)与派发快照(qaReqConvMsg)共用这一份标记,
248
+ * 否则快照里的头像会跟实时那份走形。 */
249
+ function qaUserInitial(){
250
+ const el = $("#nu-name");
251
+ const nm = ((el && el.textContent) || "").trim();
252
+ if(nm) return nm.slice(0, 1);
253
+ /* 取不到显示名时:宿主可以给 `QA_USER_INITIAL`(**可选**符号,见 shared/conv/README.md);
254
+ 都没有就退到「我」—— ⚠️ 不要退到工作台那位用户的姓名首字:那是别人,
255
+ 手机上顶着别人的名字会让人以为「这条是我发的 / 这是谁」(2026-10-17)。 */
256
+ const given = (typeof QA_USER_INITIAL === "string" && QA_USER_INITIAL) ? QA_USER_INITIAL : "";
257
+ return (given || "我").slice(0, 1);
258
+ }
259
+ /* **数字分身的头像图标**(2026-09-21 用户:「应该使用1个机器人图标头像吧」):
260
+ 一枚简笔机器人头 —— 天线 + 圆角方脸 + 两只眼 + 一道嘴 + 两侧耳线;`currentColor` 上色,
261
+ 颜色与底由 CSS 给(`.qa-av-sim`,朱砂实底 + 白线)。
262
+ 为什么用机器人图标而不是「分」字:它是**一个引擎在演人**,机器人头一眼说明「这不是真人」;
263
+ 而「分」字第一眼看不出是什么,还得配说明。 */
264
+ const QA_AV_SIM_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
265
+ + '<path d="M12 6.7V4.5"/><circle cx="12" cy="3.3" r="1.15" fill="currentColor" stroke="none"/>'
266
+ + '<rect x="4.7" y="6.7" width="14.6" height="11.9" rx="3.5"/>'
267
+ + '<path d="M3.4 11.6v2.6M20.6 11.6v2.6"/>'
268
+ + '<circle cx="9.4" cy="11.9" r="1.25" fill="currentColor" stroke="none"/>'
269
+ + '<circle cx="14.6" cy="11.9" r="1.25" fill="currentColor" stroke="none"/>'
270
+ + '<path d="M9.7 15.6h4.6"/></svg>';
271
+
272
+ function qaAvatarHtml(role){
273
+ if(role === "mine") return `<span class="qa-av qa-av-txt" aria-hidden="true">${escHtml(qaUserInitial())}</span>`;
274
+ if(role === "review") return `<span class="qa-av qa-av-txt qa-av-rev" aria-hidden="true">审</span>`;
275
+ /* **数字分身**(2026-09-21 用户:「数字分身在右边显示专门的头像即可 不需要标注1个模拟用户的标签」→
276
+ 「应该使用1个机器人图标头像吧」):它演的是用户,但不是用户本人 —— 所以既不复用用户头像
277
+ (那会让人以为是我自己发的),也不只挂一枚「模拟用户」小标(标签挡在气泡里,读起来像注释);
278
+ 给它一枚**自己的**机器人头像(同上 QA_AV_SIM_SVG)。
279
+ ⚠️ 与「审」同一条做法:头像由这里统一给,别在业务代码里手搓 span。 */
280
+ if(role === "sim") return `<span class="qa-av qa-av-sim" aria-hidden="true">${QA_AV_SIM_SVG}</span>`;
281
+ return qaBotAvatarHtml();
282
+ }
283
+ /** bot 一侧的头像:宿主给 `QA_BOT_AVATAR_SRC` 就用它(**可选**符号),没给就退到
284
+ * magic-test 工作台那张应用图标 `assets/icon.png`(那边一直是这个路径,不动它)。
285
+ * ⚠️ 为什么要有这层:组件两端的资产目录里**没有** `assets/icon.png`(那边只有字体文件),
286
+ * 于是移动端每条助手消息的左上角都是一枚**裂图**(2026-10-17 用户在截图里看到的就是它)。
287
+ * 宿主把它给成空串 = 「我没有这张图」→ 走下面的中性机器人头,宁可是简笔图标也别裂着。 */
288
+ const QA_AV_BOT_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"'
289
+ + ' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
290
+ + '<path d="M12 6.7V4.5"/><circle cx="12" cy="3.3" r="1.15" fill="currentColor" stroke="none"/>'
291
+ + '<rect x="4.7" y="6.7" width="14.6" height="11.9" rx="3.5"/>'
292
+ + '<path d="M3.4 11.6v2.6M20.6 11.6v2.6"/>'
293
+ + '<circle cx="9.4" cy="11.9" r="1.25" fill="currentColor" stroke="none"/>'
294
+ + '<circle cx="14.6" cy="11.9" r="1.25" fill="currentColor" stroke="none"/>'
295
+ + '<path d="M9.7 15.6h4.6"/></svg>';
296
+ function qaBotAvatarHtml(){
297
+ const src = (typeof QA_BOT_AVATAR_SRC === "string") ? QA_BOT_AVATAR_SRC : "assets/icon.png";
298
+ if(src) return `<span class="qa-av"><img class="qa-av-img" src="${escHtml(src)}" alt="" draggable="false"></span>`;
299
+ return `<span class="qa-av qa-av-bot" aria-hidden="true">${QA_AV_BOT_SVG}</span>`;
300
+ }
301
+ /* **引擎名小标**(2026-09-22 用户:「头像旁边显示引擎名字」;同日追加「名字显示的大一点
302
+ 再加上模型显示」→ 文案 = 「引擎 · 模型」,字号见 app.css M8):左侧消息头像独占一行(见文末 M7)
303
+ 之后,头像那一行右侧空着 —— 把「这一条是哪个引擎、哪个模型说的」挂在头像右边。
304
+ 这里只负责吐节点;**挂不挂、挂哪一档**由渲染层决定(右侧两档不挂:它们靠头像字 / 抬头点明身份,
305
+ 见 app.css 的 `.qa-who` 只认左侧形状;⚠️ 审核引擎那条 2026-09-30 起也在左侧,但它的小标挂在
306
+ **机器侧那条独立消息**上,报告本体不重复挂 —— 见 13-dispatch-stream 与 qaTraceToLeft)。
307
+ 空引擎名 → 空串:宁可没有,也不写「未知」占位。
308
+ 模型存的是 **id**,显示走 qaModelName(与「生成中」胶囊同一套分工:名字只活在展示层)。
309
+ ⚠️ `modelOnly`(2026-10-20 用户:「只显示模型即可 不然现在换行了」):头像那一行上并着
310
+ 「运行中 · 耗时计时」,而这一行是 `flex-flow:row wrap` —— 「引擎 · 模型」整串
311
+ (引擎名 + 模型显示名,两段都能很长)会把余下宽度吃光,秒数被挤到**换行**,
312
+ 头像那一行于是散成两截。这一档只留模型名(模型为空时退回引擎名,宁可短不可换行)。 */
313
+ function qaWhoLabel(engine, model, modelOnly){
314
+ const nm = String(engine == null ? "" : engine).trim();
315
+ if(!nm) return "";
316
+ const md = String(model == null ? "" : model).trim();
317
+ const mName = md ? qaModelName(md, nm) : "";
318
+ if(modelOnly) return mName || nm;
319
+ return mName ? `${nm} · ${mName}` : nm;
320
+ }
321
+ function qaWhoHtml(engine, model, modelOnly){
322
+ const lb = qaWhoLabel(engine, model, modelOnly);
323
+ return lb ? `<span class="qa-who">${escHtml(lb)}</span>` : "";
324
+ }
325
+ /* ===== 消息操作行:复制 / 重试(2026-10-09 用户:「消息要加上复制和重试」)=====
326
+ 每条消息(用户自己那条 + 引擎答复那条)正文之下挂一行操作:
327
+ · 复制 = 把这条消息的正文原样复制走;
328
+ · 重试 = 把这一轮**再问一遍**(用户那条用它自己那句话;引擎那条向上找最近的那条
329
+ 用户消息 —— 它当初就是被那句话问出来的),引擎重新作答。
330
+ ⚠️ 按钮**不带监听**:点击走 #qa-stream 的事件委托(07 的 bindQaStreamDelegation)——
331
+ 快照恢复(`s.innerHTML = snapshot`)会把节点整份重建,直接 addEventListener 会随旧节点
332
+ 一起丢,历史消息上的按钮就成了死的(与工具卡 / 代码面板的复制按钮同一条纪律)。
333
+ ⚠️ 状态一律落在 class / hidden 上(`.copied`):它们跟着 HTML 活下来,快照往返后形态不漂。
334
+ ⚠️ 「要复制的正文」落位**固定 = `.qa-t`**(用户那条与引擎那条都是它,见两个渲染入口)——
335
+ 委托侧按这个约定取文本,所以按钮本身不必带任何 data-*。 */
336
+ function qaMsgActsHtml(){
337
+ return '<button type="button" class="qa-act qa-act-copy" aria-label="复制" title="复制这条消息">'
338
+ + '<span class="i-copy">' + QA_V_ICONS.copy + '</span><span class="i-ok">' + QA_V_ICONS.ok + '</span></button>'
339
+ + '<button type="button" class="qa-act qa-act-retry" aria-label="重试" title="重试这一轮">'
340
+ + QA_V_ICONS.retry + '</button>';
341
+ }
342
+ /** 造一行操作(返回节点)。引擎那条答复先 `hidden` —— 流式期间正文还只有半句,
343
+ 这时让「复制 / 重试」亮着会让人在半截话上点到;收尾(qaRunEnd → finish)再显形。 */
344
+ function qaMsgActsNode(hidden){
345
+ const d = document.createElement("div");
346
+ d.className = "qa-acts";
347
+ if(hidden) d.hidden = true;
348
+ d.innerHTML = qaMsgActsHtml();
349
+ return d;
350
+ }
351
+ /** 一条消息的正文纯文本(复制 / 重试用同一个出口,见上面那段「落位固定」)。 */
352
+ function qaMsgTextOf(msg){
353
+ const bd = msg && msg.querySelector(".qa-t");
354
+ return bd ? String(bd.textContent == null ? "" : bd.textContent).trim() : "";
355
+ }
356
+ /** 重试一条消息:向上找「这一轮是从哪句话问出来的」,再走 qaSend 重跑。
357
+ * ⚠️ 仍然走 qaSend(followup + echo:false)—— 排队 / 锁 / 附件闸门 / 快照落库
358
+ * 这些既有约定一条都不用重写:重试与正常发送走的是**同一条链路**。
359
+ * ⚠️ echo:false = **不重复画**一条用户气泡:重试是「同一句话再来一次」,
360
+ * 再长出一条一模一样的用户消息只会让对话看起来被复制了一遍。 */
361
+ function qaMsgRetry(msg){
362
+ if(!msg) return;
363
+ let src = msg;
364
+ if(!msg.classList.contains("qa-mine")){
365
+ src = null;
366
+ let p = msg.previousElementSibling;
367
+ while(p){
368
+ if(p.classList && p.classList.contains("qa-msg") && p.classList.contains("qa-mine")){ src = p; break; }
369
+ p = p.previousElementSibling;
370
+ }
371
+ /* 引擎那条答复找不到「它是被哪句话问出来的」就**不重试**:把答复本身当提问发回去
372
+ 毫无意义(引擎会去答一条它自己刚说的话)。宁可如实说一句,也不发一条怪消息。 */
373
+ if(!src){ toast("找不到这条答复对应的提问,没法重试"); return; }
374
+ }
375
+ const text = qaMsgTextOf(src);
376
+ if(!text){ toast("这条消息没有可重试的正文"); return; }
377
+ /* 一轮没跑完就重试,等于往队列里塞一条「重跑」—— 语义含糊(排队的那条本来就会跑)。
378
+ 如实说、不静默排队,与追加/模式轮的拦截同一口径。 */
379
+ if(typeof qaTaskRunning === "function" && qaTaskRunning(qaActiveId)){ toast("这条会话还在跑,等它结束再重试"); return; }
380
+ toast("正在重试 · " + escHtml(text.slice(0, 24)) + (text.length > 24 ? "…" : ""));
381
+ qaSend(text, { followup: true, echo: false });
382
+ }
383
+ function qaRenderMine(text, atts){
384
+ const s = $("#qa-stream"); if(!s) return;
385
+ s.hidden = false;
386
+ const div = document.createElement("div");
387
+ div.className = "qa-msg qa-mine";
388
+ div.innerHTML = `<div class="qa-ico">${qaAvatarHtml("mine")}</div><div class="qa-bd"><div class="qa-t md-body"></div></div>`;
389
+ // 用户消息按 markdown 渲染:marked + DOMPurify(沿用产品文档同一管线,输入是用户可控,
390
+ // 但消毒仍保留以防恶意粘贴)。
391
+ const host = div.querySelector(".qa-t");
392
+ /* 正文 + 正文里那些截图路径一起落笔:需求正文(`[粘贴的截图]` + 绝对路径)与飞书带图消息
393
+ (`[图片] <路径>`)的图**走不了附件通道**,只能把路径写进正文 —— 渲染时把它们摘出来
394
+ 画成缩略图,别让用户对着一串 /Users/… 猜自己发了什么(见 19-attachments 的 qaShotsMount)。
395
+ 代码块的高亮补扫也在它里面。 */
396
+ qaShotsMount(host, text);
397
+ // 随消息发出的截图(只有图没文字时 host 为空,此时这一行就是气泡的全部内容)。
398
+ // 用 DOM 拼节点而不是拼 HTML 字符串:缩略图地址来自落盘返回值,
399
+ // 走 setAttribute 天然免去一次转义,也让「没拿到地址就跳过」这种判断更直白。
400
+ if(atts && atts.length){
401
+ const row = document.createElement("div");
402
+ row.className = "qa-atts";
403
+ atts.forEach((a)=>{
404
+ const u = attThumbUrl(a);
405
+ if(!u) return;
406
+ row.appendChild(qaThumbNode(u, (a.name || "截图") + " · " + attSize(a.size), a.name || "截图"));
407
+ });
408
+ if(row.children.length) host.appendChild(row);
409
+ }
410
+ /* 长消息折叠(2026-09-28,对齐 anywhere 的 CollapsibleUserMessage):用户在这个工作台里
411
+ 最常干的事就是把整份需求/上下文粘进来,一条消息能把画布顶掉两屏 —— anywhere 给用户消息
412
+ 的解法是「超阈值就截断 + 底部渐隐 + 一枚展开全文(内容一个字不删,只改可见高度)」。
413
+ ⚠️ 必须放在**附件之后**:判据看的是渲染后的 DOM(host 里有没有 `.qa-atts`),
414
+ 带图的消息不折 —— 否则「我特意要你看的图」会被一起折掉(见 29-msg-variants 的 qaFoldMaybe)。
415
+ ⚠️ 折叠态是个**类**(`.qa-fold`),落在 HTML 上 → 快照恢复后高度不会弹回去。 */
416
+ qaFoldMaybe(host, text);
417
+ /* 操作行(复制 / 重试,2026-10-09):挂在**消息根**上(不是 .qa-bd 里)——
418
+ 用户那条的 .qa-bd 就是气泡本体,操作行落在气泡里会被当成气泡内容;
419
+ 挂在根上则落在气泡**下方**(右侧那两档靠 app.css 的折行把整行推到气泡下沿)。
420
+ 引擎那条(左侧)本身就是纵向的,挂在根上天然就在正文下方。 */
421
+ div.appendChild(qaMsgActsNode(false));
422
+ s.appendChild(div);
423
+ /* ⚠️ 落笔即贴底(2026-09-23 用户报障:「追问问题 但是用户的问题不会触发滚动到最新
424
+ 需要等 agent 回复才会触发」)。
425
+ 根因:**自己这条气泡是唯一不滚的一笔** —— 引擎侧每一笔(appendText / toolCall /
426
+ setResult / 收尾行,见本文件各处 qaScrollIntoView)与数字分身那枚气泡
427
+ (24-agent-test 的 atcTwinMsg)落笔后都会贴底,只有这里没有。
428
+ 于是「上一轮答得长、画布停在中间」时,刚发出去的那条整条落在折线以下 —— 用户看不见
429
+ 自己刚问的话,得等引擎吐出第一段正文(09-ask-event 每次事件末尾那次滚动)才被动拽到底。
430
+ 排队那条路径(qaMsgQueue)表现一样:轮到它自动提交时同样只有这条不滚,观感还是
431
+ 「我的问题没滚过去、等回复才滚」。
432
+ ⚠️ 这一行是**唯一**该滚的地方:qaRenderMine 就是「自己说的话落进画布」的唯一出口,
433
+ 放在这里,实时发送 / 排队提交 / 派发快照三条路径一并贴底;放到调用方(qaSend)去补,
434
+ 下一次多一条调用路径就又会漏。
435
+ ⚠️ 发送 = **用户的明确意图**:清掉「他正翻着历史」那个标记(qaUserAway),
436
+ 否则他跳上去看历史之后发的那条会落在折线以下、看不见自己刚问的话。 */
437
+ qaUserAway = false;
438
+ qaScrollIntoView();
439
+ }
440
+ /** 「引擎 · 模型 · 生成中」= 1 枚胶囊(用户 2026-09-17):
441
+ * 转圈 + 文字同装一枚圆角胶囊(.qa-run-pill),实时生成行与派发快照的「生成中」状态行
442
+ * 共用这一份标记 —— 两处各写一份必然走形(一处改了另一处忘)。
443
+ * 文字仍走 qaModelName(显示名),`-m` 用的是 id,与全链路的分工一致。 */
444
+ function qaRunPillHtml(text){
445
+ return `<span class="qa-run-pill"><span class="qa-spinner"></span>`
446
+ + `<span class="qa-run-t">${escHtml(text)}</span></span>`;
447
+ }
448
+ function qaRenderAnswer(host){
449
+ // 创建一条 AI 应答消息(思考区 + 工具卡区 + 正文区 + 运行态行),返回操作句柄
450
+ // host = 这一轮的落笔处(可见画布 #qa-stream,或后台 run 的 holder,见 qaRunHost)——
451
+ // 绝不能写死 #qa-stream:生成中切到别的会话时,这一轮的增量必须落在**自己**的画布里。
452
+ const s = host || $("#qa-stream"); if(!s) return null;
453
+ if(s.hidden) s.hidden = false;
454
+ const div = document.createElement("div");
455
+ div.className = "qa-msg";
456
+ /* ===== 时间轴排版(2026-10-12 用户:「桌面也改成严格按照时间轴」)=====
457
+ 内容区(.qa-bd)是一串**按到达顺序**追加的块,不再预置「思考 / 工具 / 正文」三段:
458
+ · .qa-think —— 一段连续 thinking = 一块
459
+ · .qa-tools —— 一段连续工具调用 = 一块(组头 + 组内卡)
460
+ · .qa-t —— 一段连续正文 = 一块
461
+ · .qa-tool / .qa-agent / .qa-mk / .qa-sep —— 各是一笔,直接落进流里
462
+ 规则只有一条:**新的一笔落到队尾**;换块即「封口」(旧的思考块停表、旧的正文段把尾部
463
+ 落成真块),后续同类内容另起一块。
464
+ 改前是 `bd.insertBefore(卡, body)` —— 所有过程项都压在正文之上,于是「写一段 → 跑工具 →
465
+ 再写一段」的时间关系被抹平(工具总在最上面)。现在如实保留先后。 */
466
+ div.innerHTML = `<div class="qa-ico">${qaAvatarHtml("ai")}</div><div class="qa-bd"></div>`;
467
+ /* 这一轮的**起点**(墙上时钟):收尾时给「任务耗时」分组头算时长用(见 finish → wrapTurn)。
468
+ 与收尾行那一段「· 总耗时 X」同一量(它是 `run.t0 → 此刻`,两者相差毫秒级)。 */
469
+ div.__qaT0 = Date.now();
470
+ /* 头像右边默认挂**当前引擎 · 当前模型**(用户 2026-09-22:「头像旁边显示引擎名字」+
471
+ 「再加上模型显示」)。模型初值与「生成中」胶囊同源(`qaModel` = 链首 / 任务自定)。
472
+ ⚠️ 这是默认值:说话人不是当前引擎时由调用方用句柄的 `setWho` 改写
473
+ (派发轮 = 该需求的引擎 / 模型,见 13-dispatch-stream;被测引擎 = 事件里的 p.engine,见 24-agent-test);
474
+ 主进程降级换模型时由 `start` 事件改写(09-ask-event,与胶囊同一时机)。
475
+ ⚠️ 挂上去**不等于看得见**:`.qa-who` 的样式只认左侧形状(见 app.css 的 M8),
476
+ 右侧那两档(我 / 数字分身)本就不该显示它;审核引擎那条走审查轮时会把这枚 `.qa-ico`
477
+ 整个换成「审」头像(小标随之被替换掉,见 13-dispatch-stream)—— 那条虽已贴左(2026-09-30),
478
+ 但它的小标由机器侧那条独立消息承担(`qaTraceToLeft`),报告本体不再重复挂一枚。 */
479
+ const icoEl = div.querySelector(".qa-ico");
480
+ if(icoEl) icoEl.insertAdjacentHTML("beforeend", qaWhoHtml(qaEngine, qaModel));
481
+ /* 操作行(复制 / 重试,2026-10-09):先藏起来,本轮收尾(finish)再显形 ——
482
+ 流式期间正文只有半句,这时能点「复制 / 重试」只会让人复制到半截话。 */
483
+ const acts = qaMsgActsNode(true);
484
+ div.appendChild(acts);
485
+ s.appendChild(div);
486
+
487
+ /* ══════════ 时间轴:内容区与三类「当前块」 ══════════ */
488
+ const bd = div.querySelector(".qa-bd");
489
+ /* 机器侧落笔处(审核 / 数字分身那两档):思考与工具是**机器在动**,要落到前面那条独立
490
+ 消息里(见 qaTraceToLeft)。壳由那一侧建 —— **即使此刻一块都还没有**也要建出来,
491
+ 否则这一轮里后起的机器块会落错地方(旧实现预置了空的思考块/工具组,恰好掩盖了这点)。 */
492
+ const machineHost = ()=> (div.__traceWrap && div.__traceWrap.querySelector(".qa-bd")) || bd;
493
+ let curThink = null, curGroup = null, curText = null; // 当前「打开」的三类块
494
+ /* 本条消息里**建过的所有**工具组(不只当前那个)——收尾兜底要能封到「被 endToolRun 摘掉
495
+ 引用、之后又被卡片落定重新点亮」的那种组(见 settleGroups 的说明)。 */
496
+ const groups = [];
497
+ /* 「本轮已收尾」粘性标记(2026-10-16 修「工具调用中与已完成了同框」:
498
+ 用户报障原话「工具调用中 就已完成了 判断结束是有bug吗」)。
499
+ 为什么需要它:`finish()` 跑完之后,迟到的事件照样能走到 `groupBegin` / `thinkBegin` ——
500
+ onDone 里等会话产物那段最多 1.5s 的窗口(此刻 `qaRuns` 还没删)、同一 rid 的第二条终态、
501
+ 补到的 tool_result 收编,这几条路都在此列;而这两个 begin 原来**无条件**把块点亮成 live
502
+ (绿点 + 计时器起跳),此后**没有任何一次收尾**会再来封它 → 画布上永远留着一条
503
+ 「工具调用中」,与下面那行「已完成 · 总耗时」并排。
504
+ 置位后只写**定格态**:内容照常落进块里(不丢),但不再进 live、不起表。
505
+ ⚠️ 已在 `wrapTurn` 里并进「任务耗时」大组的过程块不会再被拆出来 —— 迟到的那一笔只能
506
+ 停在组外,这是收尾之后才到的代价,好过留一条永远在转的组头。 */
507
+ let closed = false;
508
+ let pending = ""; // 当前正文块里尚未闭合的尾部
509
+ const textBlocks = []; // 本回合所有正文块(权威全文覆盖看段数)
510
+ /* ===== 「段」:过程中就把连续的过程块并成一行(2026-10-12 用户:「最终是要合并
511
+ 但是过程中也需要分段按组合并 被文本消息分割就是多段」)=====
512
+ · **段 = 一段连续的过程**(思考块 / 工具组)。正文、标记行、分隔条、提问卡一到,
513
+ 这一段就封口(sealSeg)→ 下一次过程另起一段;
514
+ · 段行与收尾那枚「任务耗时」大组**同一形制**(`.qa-turn`,多一个 `data-seg` 标记)。
515
+ · ⚠️ **段内随时收,不再铺成一长串**(2026-10-15 用户报障:「怎么没分组合并 又是显示很长的
516
+ 思考和工具调用」):一段里只留最新 `SEG_KEEP` 笔过程在外面(当前在做什么照旧一眼可见),
517
+ 更早的**每来一笔新过程就收进段行**。改前只在正文到达时才收 —— 而实测那种一轮(dsh:
518
+ 21 段思考 + 24 次工具、正文只在最后一句)从头到尾没有正文打断,于是整屏都是
519
+ 「已深度思考 0.0s / 已调用工具 · 2个 3.3s」,用户要的合并一直不出现。
520
+ · **运行中只留一行**(2026-10-20 用户报障,移动端:「agent 运行中不需要[分开],
521
+ 合并到下一行即可」):`SEG_KEEP` 从 2 收到 **1** —— 新一笔过程一开始(工具组或下一段
522
+ 思考点亮),上一笔就立刻被 `segFold` 收进段行,画布上永远只有「本段进行中的那一笔」。
523
+ 收尾仍由 wrapTurn 并成一枚大组,最终形态不变。
524
+ · **收尾时这些段再合并成一枚大组**(见 wrapTurn)—— 最终只留一行。
525
+ `segs` = 已并出的段壳(收尾要拆掉它们、把成员搬进那枚大组);
526
+ `segT0` = 当前段第一笔过程块的时间(段行上那个「任务耗时 X」算的是**这一段**花了多久)。 */
527
+ const segs = [];
528
+ let segT0 = 0;
529
+ const SEG_KEEP = 1; // 段内最多留几笔过程在外面
530
+
531
+ /* 过程块落在哪些容器里:答复那条,外加**机器侧那条**(审核轮 / 数字分身的思考与工具被
532
+ qaTraceToLeft 搬进它自己的 `.qa-bd`)—— 两处都要能并,不能只认 bd。 */
533
+ function segHosts(){
534
+ const out = [bd];
535
+ const w = div.__traceWrap && div.__traceWrap.querySelector(".qa-bd");
536
+ if(w && w !== bd) out.push(w);
537
+ return out;
538
+ }
539
+ const segParts = (host)=> [...host.querySelectorAll(":scope > .qa-think, :scope > .qa-tools")];
540
+ /* 本段**还没封口**的段壳(封过的那几枚留在画布上,绝不能再被复用 —— 复用会把两段并成一行、
541
+ 正文也跟着挤到一起,实测踩过)。开合两态都留 `data-seg`(收尾要按它把段壳展开成成员)。 */
542
+ const segShell = (host)=> host.querySelector(":scope > .qa-turn[data-seg]:not([data-seg-done])");
543
+ function mkSegShell(host, before){
544
+ const d = document.createElement("details");
545
+ d.className = "qa-turn";
546
+ d.dataset.seg = "1"; // 「过程中的段」标记(收尾时会被拆掉)
547
+ d.innerHTML = `<summary class="qa-turn-sum"><span class="qa-turn-t"></span><span class="qa-turn-n"></span></summary><div class="qa-turn-bd"></div>`;
548
+ host.insertBefore(d, before || null);
549
+ segs.push(d);
550
+ return d;
551
+ }
552
+ /* 段行上那两个数:耗时算的是**这一段**(segT0 起算),数的是壳里装了几笔 */
553
+ function segSync(d){
554
+ if(!d) return;
555
+ const n = d.querySelector(":scope > .qa-turn-bd").childElementCount;
556
+ const dur = segT0 ? Date.now() - segT0 : 0;
557
+ d.querySelector(".qa-turn-t").textContent = "任务耗时" + (dur ? " " + qaDurText(dur) : "");
558
+ d.querySelector(".qa-turn-n").textContent = n + " 项过程";
559
+ }
560
+ /* 本段又多了一笔过程 → 超过 SEG_KEEP 的更早那几笔收进段行(壳懒建,落在第一笔成员的位置上)。
561
+ ⚠️ 只搬 `.qa-think` / `.qa-tools`(直系子节点)—— **正在跑的工具卡留在外面**
562
+ (`.qa-tool-alone`,「只有最新的 1 个运行中显示在外面」那条口径);它落定时自己会收进
563
+ 本段的工具组(见 collapseIntoGroup),那时才跟着进来。
564
+ ⚠️ 已经被段壳包住的块不再被 `:scope >` 命中 → 天然幂等(重复调用只会把新长出来的那几笔并走)。 */
565
+ function segFold(){
566
+ segHosts().forEach((host)=>{
567
+ const parts = segParts(host);
568
+ if(parts.length <= SEG_KEEP) return;
569
+ const d = segShell(host) || mkSegShell(host, parts[0]);
570
+ const body = d.querySelector(":scope > .qa-turn-bd");
571
+ parts.slice(0, parts.length - SEG_KEEP).forEach((p)=> body.appendChild(p)); // 搬节点,不重建
572
+ segSync(d);
573
+ });
574
+ }
575
+ /* 把**当前这一段**剩下的过程并进段行并封口:段落边界 = 正文 / 标记行 / 分隔条 / 提问卡
576
+ * (见各处的 sealSeg 调用)。壳外还留着的几笔(SEG_KEEP 以内)也一起收进去 —— 段到此为止,
577
+ * 下一笔过程另起一段(收尾时再由 wrapTurn 把所有段并成一枚大组)。 */
578
+ function sealSeg(){
579
+ let out = null;
580
+ segHosts().forEach((host)=>{
581
+ const parts = segParts(host);
582
+ const d = segShell(host) || (parts.length ? mkSegShell(host, parts[0]) : null);
583
+ if(!d) return;
584
+ const body = d.querySelector(":scope > .qa-turn-bd");
585
+ parts.forEach((p)=> body.appendChild(p));
586
+ segSync(d);
587
+ d.dataset.segDone = "1"; // 封口:这一枚不再吸收后面的过程
588
+ out = out || d;
589
+ });
590
+ segT0 = 0; // 下一笔过程算下一段
591
+ return out;
592
+ }
593
+
594
+ /* 正文块的尾部落成真块(换块 / 收尾共用一处;与旧 finish 的「尾部 pending 解析一次」同源) */
595
+ function commitTail(){
596
+ if(!curText){ pending = ""; return; }
597
+ const old = curText.querySelector(".qa-tail");
598
+ if(old) old.remove();
599
+ if(pending){
600
+ const tmp = document.createElement("div");
601
+ qaShotsMount(tmp, pending);
602
+ [...tmp.childNodes].forEach(node=> curText.appendChild(node));
603
+ highlightIn(curText);
604
+ }
605
+ pending = "";
606
+ }
607
+ function sealText(){ if(curText) commitTail(); curText = null; pending = ""; }
608
+ /* 一段工具调用的结束:组定格(下一段工具另起一组) */
609
+ function endToolRun(){ if(curGroup) groupEnd(curGroup); }
610
+ /* 横切事件(标记行 / 分隔条 / 未配对提问卡)→ 三类块一起封口 */
611
+ function sealAll(){ sealThink(); endToolRun(); sealText(); sealSeg(); }
612
+
613
+ /* ===== 深度思考块(一段连续 thinking 一块)=====
614
+ thinkBegin 首次调用即进入 live:朱砂呼吸点 + 「思考中」+ 秒数实时跳动,
615
+ 思考默认收起(live 也只亮指示,不自动展开;用户手动开合过则尊重用户);
616
+ thinkEnd 定格为「已深度思考 · X.Xs」并维持收起 —— 之后用户仍可点开回看。
617
+ ⚠️ 开合点击统一走 #qa-stream 上的**事件委托**(见 bindQaStreamDelegation):
618
+ 快照恢复(`s.innerHTML = t.snapshot`)会把节点整个重建 —— 直接 addEventListener
619
+ 绑在卡片上的监听随旧节点一起消失,「恢复历史对话后点工具卡/思考条没反应」
620
+ (2026-09-16 用户报障:WebSearch 点击无法展开)。用户手动开合过 → 记 data-touched。 */
621
+ function ensureThink(){
622
+ if(curThink) return curThink;
623
+ endToolRun(); sealText(); // 换块:正文与这一段工具到此为止
624
+ const el = document.createElement("div");
625
+ el.className = "qa-think";
626
+ el.hidden = true;
627
+ el.innerHTML = `<span class="qa-think-toggle"><span class="tt">深度思考</span><span class="qa-think-dur" hidden></span><svg class="chev" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg></span><div class="qa-think-bd"></div>`;
628
+ machineHost().appendChild(el);
629
+ curThink = { el, tt: el.querySelector(".tt"), dur: el.querySelector(".qa-think-dur"),
630
+ body: el.querySelector(".qa-think-bd"), start: 0, timer: null,
631
+ touched: ()=> el.dataset.touched === "1" };
632
+ return curThink;
633
+ }
634
+ function thinkBegin(c){
635
+ const t = c || ensureThink();
636
+ t.el.hidden = false;
637
+ /* 本轮已收尾(见 closed):同 groupBegin —— 只写定格态,不进 live */
638
+ if(closed){
639
+ t.el.classList.remove("live");
640
+ t.tt.textContent = "已深度思考";
641
+ return t;
642
+ }
643
+ t.el.classList.add("live");
644
+ t.tt.textContent = "思考中";
645
+ /* 机器内部活动首次显形 → 把左边那条「机器侧」消息也显出来(见 qaTraceToLeft)。
646
+ 没做过拆分时 __traceWrap 是 undefined,这一行就是空操作。 */
647
+ if(div.__traceWrap) div.__traceWrap.hidden = false;
648
+ if(!t.start) t.start = Date.now();
649
+ if(!segT0) segT0 = Date.now(); // 本段的第一笔过程(段行的耗时从这儿算)
650
+ segFold(); // 新的一笔过程 → 本段更早的几笔收进段行
651
+ if(!t.timer) t.timer = setInterval(()=>{
652
+ t.dur.hidden = false;
653
+ t.dur.textContent = ((Date.now() - t.start) / 1000).toFixed(1) + "s";
654
+ }, 100);
655
+ return t;
656
+ }
657
+ function thinkEnd(c){
658
+ const t = c || curThink; if(!t) return;
659
+ if(t.start){ // 从未思考过则什么都不显示
660
+ clearInterval(t.timer); t.timer = null;
661
+ t.el.classList.remove("live");
662
+ t.tt.textContent = "已深度思考";
663
+ t.dur.hidden = false;
664
+ t.dur.textContent = ((Date.now() - t.start) / 1000).toFixed(1) + "s";
665
+ if(!t.touched()) t.el.classList.remove("open"); // 用户没手动开过 → 维持收起
666
+ }
667
+ if(t === curThink) curThink = null;
668
+ }
669
+ const sealThink = ()=> thinkEnd(curThink);
670
+
671
+ /* ===== 工具调用组(一段连续工具调用一组)=====
672
+ 折叠条显示「工具调用 · N个 · X.Xs」,点击展开看组里的卡。
673
+ ⚠️ 组里放**没有 description** 的调用(2026-09-18),外加**超编的**带 description 的独立卡
674
+ (正文只留最近 5 张,更早的收编进组,见 ALONE_KEEP / groupAdd);所以这里的 N 数的是
675
+ 「组里实际有几张卡」,与正文里那几张独立卡无关(2026-09-20)。
676
+ ⚠️ 组头**懒启动**(2026-09-18):第一张卡到达时才点亮 —— 全是 description 的一段
677
+ 不会留下一个「0个」的空组。
678
+ groupBegin 首次调用即进入 live:绿点 + 调用数实时跳动,工具默认收起(live 也只亮指示,
679
+ 不自动展开;用户手动开合过则尊重用户);groupEnd 定格为「已调用工具 · N个 · X.Xs」并维持收起。
680
+ ⚠️ 组**按需创建**(第一张真正归组的卡到达时才建,位置就是那一笔的队尾)——
681
+ 全是一句话 description 的独立卡时,画布上不会多出一个空的折叠组。 */
682
+ function ensureGroup(){
683
+ if(curGroup) return curGroup;
684
+ sealThink(); sealText(); // 换块:思考与正文在这一段之前结束
685
+ const el = document.createElement("div");
686
+ el.className = "qa-tools";
687
+ el.hidden = true;
688
+ el.innerHTML = `<span class="qa-tools-toggle"><span class="tt">工具调用</span><span class="qa-tools-count"></span><span class="qa-tools-dur"></span><svg class="chev" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg></span><div class="qa-tools-bd"></div>`;
689
+ machineHost().appendChild(el);
690
+ if(!segT0) segT0 = Date.now(); // 同 thinkBegin:工具也是「过程」,算本段起点
691
+ curGroup = { el, tt: el.querySelector(".tt"), count: el.querySelector(".qa-tools-count"),
692
+ dur: el.querySelector(".qa-tools-dur"), body: el.querySelector(".qa-tools-bd"),
693
+ start: 0, timer: null, num: 0, touched: ()=> el.dataset.touched === "1" };
694
+ groups.push(curGroup);
695
+ return curGroup;
696
+ }
697
+ function groupBegin(g){
698
+ const t = g || ensureGroup();
699
+ t.el.hidden = false;
700
+ /* 本轮已收尾(见 closed):直接写定格态 —— 点亮了也没人会来封它 */
701
+ if(closed){
702
+ t.el.classList.remove("live");
703
+ t.tt.textContent = "已调用工具";
704
+ return t;
705
+ }
706
+ t.el.classList.add("live");
707
+ t.tt.textContent = "工具调用中";
708
+ if(div.__traceWrap) div.__traceWrap.hidden = false; // 同 thinkBegin:显出「机器侧」那条
709
+ if(!t.start) t.start = Date.now();
710
+ segFold(); // 同 thinkBegin:新的一笔过程 → 收更早的那几笔
711
+ if(!t.timer) t.timer = setInterval(()=>{
712
+ t.dur.hidden = false;
713
+ t.dur.textContent = ((Date.now() - t.start) / 1000).toFixed(1) + "s";
714
+ }, 100);
715
+ return t;
716
+ }
717
+ function groupEnd(g){
718
+ const t = g || curGroup; if(!t) return;
719
+ if(t.start){
720
+ clearInterval(t.timer); t.timer = null;
721
+ t.el.classList.remove("live");
722
+ t.tt.textContent = "已调用工具";
723
+ t.dur.hidden = false;
724
+ t.dur.textContent = ((Date.now() - t.start) / 1000).toFixed(1) + "s";
725
+ if(!t.touched()) t.el.classList.remove("open");
726
+ }
727
+ if(t === curGroup) curGroup = null;
728
+ }
729
+ /* 收尾兜底(2026-09-18):这一轮结束时,画布上**还在转圈**的工具卡一律落定。
730
+ 为什么需要它:一条事件里装了多次调用时,tool_result 只带一个 id,只有第一张卡配得上,
731
+ 其余几张(现在都作为独立卡摆在正文流里,转圈格外扎眼)原来会永远转 —— 连同
732
+ 「点停止」那条收尾路径(qaRunEnd 不经过 finishTools)也一起被这里收掉。
733
+ 钩子 `__qaDone` 由 mkCard / agentCall 挂在节点上(委派卡的是带胶囊的那版);
734
+ 快照恢复出来的历史卡没有钩子,但它们本来就不是 running,扫不到。 */
735
+ function settleRunningTools(ok){
736
+ div.querySelectorAll('.qa-tool[data-state="running"]').forEach(el=>{
737
+ if(typeof el.__qaDone === "function"){ try{ el.__qaDone("", ok); }catch(e){} }
738
+ });
739
+ }
740
+ /* 收尾兜底(2026-10-15,用户报障「冲突 · 调用中又已完成」):把本条消息里**所有还亮着的**
741
+ 工具组一律定格 —— 不是只封 `curGroup` 那一个。
742
+ 为什么只封 curGroup 会漏(实测出来的那条路,正是截图里「已深度思考 0.0s + 工具调用中 ·
743
+ 2个 · 5.9s + 已完成 · 总耗时 10m 43s」):
744
+ · `toolCall` 先 `ensureGroup()` 建组、卡片**还在外面跑** → 组还没点亮(start=0);
745
+ · 中途来了一段正文 → `ensureText` → `endToolRun()`:组没 start,`groupEnd` 只把
746
+ `curGroup` 清成 null,**不 seal**(那个空组的节点还留在 DOM / 段壳里);
747
+ · 卡片落定(tool_result 或收尾兜底)→ `collapseIntoGroup → groupAdd → groupBegin`
748
+ 把这个组**重新点亮**(hidden=false、绿点、计时器起跳),而此刻 `curGroup` 早不是它了;
749
+ · 收尾 `finish()` 里的 `groupEnd()` 封的是 curGroup(另一个组 / 已经是 null)→
750
+ 这个组永远停在「工具调用中」,与下面的「已完成」同框。
751
+ 所以收尾按**登记表**逐个封:`g.timer` 非空 = 还活着(groupBegin 起表、groupEnd 停表),
752
+ 已定格的组跳过 —— 不跳过的话重复 finish(turn_end 之后还有 end)会把秒数越算越长。
753
+ `settleRunningTools` 必须排在它**前面**:卡片落定会往组里搬卡、还可能点亮那个组。 */
754
+ function settleGroups(){
755
+ groups.forEach(g=>{ if(g.start && g.timer) groupEnd(g); });
756
+ curGroup = null;
757
+ }
758
+
759
+ const run = document.createElement("div");
760
+ run.className = "qa-run";
761
+ run.innerHTML = qaRunPillHtml(`${qaEngine}${qaModel ? " · " + qaModelName(qaModel, qaEngine) : ""} · 生成中`);
762
+ s.appendChild(run);
763
+ /* ⚠️ 落笔即贴底(2026-09-23,与 qaRenderMine / qaRenderDone 同一约定)。
764
+ 为什么必须在**这一行之后**:这条应答的立身有两块 —— 气泡节点(上面那个 div)+ 这枚
765
+ 「生成中」胶囊行,两块加起来约 96px。它挂在**用户刚发的那条气泡之后**,用户那条贴了底、
766
+ 这一条不贴,画布底下就留着一条空白(实测 slack=96px)→ 用户看到的仍是「没滚到最新」,
767
+ 下一段正文到达时画面还会再往下跳一次。
768
+ ⚠️ 闸门在 qaScrollIntoView 里(qaPaintBg:后台 run 在画时不拽动前台画布)—— 本函数的
769
+ `host` 可指向后台画布,但滚动对象始终是可见画布,所以这里不必自己判。 */
770
+ qaScrollIntoView();
771
+
772
+ /* ===== 助手消息:流式段落缓冲(Claude / Codex 风格)=====
773
+ 模型逐 token 推送会撞碎 markdown 标签(** 加粗被切成 3 段)。
774
+ 策略:累积整段纯文本 → 按 \n\n 切块 → 已闭合段落立即走 marked 解析 →
775
+ 落到 DOM 的 <p>/<ul>/<pre> 节点上。**未闭合的尾部段落**用纯文本预览
776
+ 兜底(.qa-tail 类,便于区分"已渲染"vs"打字中")。
777
+ ⚠️ 缓冲区 `pending` **只属于当前这一块正文**:换块(工具卡 / 思考块 / 标记行落笔)时
778
+ 先把尾部落成真块(commitTail),再开新块 —— 否则前一段的尾巴会被后一段覆盖。
779
+ ⚠️ 正文块是**按需新建**的(ensureText):一段连续正文一块,位置就在队尾。 */
780
+ function ensureText(){
781
+ if(curText) return curText;
782
+ sealThink(); endToolRun(); // 换块:思考与这一段工具到此为止
783
+ sealSeg(); // ★ 正文一到 = **这一段落幕**(用户:被文本消息分割就是多段)
784
+ const t = document.createElement("div");
785
+ t.className = "qa-t streaming md-body";
786
+ bd.appendChild(t);
787
+ curText = t; textBlocks.push(t); pending = "";
788
+ return t;
789
+ }
790
+
791
+ function flushClosed(){
792
+ // 把累积文本里已闭合的段落(以 \n\n 切分)逐段解析渲染,留尾部未闭合部分在 pending
793
+ if(!curText || !pending) return;
794
+ const idx = pending.lastIndexOf("\n\n");
795
+ if(idx < 0) return;
796
+ const ready = pending.slice(0, idx);
797
+ pending = pending.slice(idx + 2);
798
+ ready.split(/\n{2,}/).forEach(chunk=>{
799
+ if(!chunk.trim()) return;
800
+ /* 一段落成:走**统一落笔处**(19-attachments::qaShotsMount)而不是直接 mdToHtmlOnly ——
801
+ bot 回复里带着**它看到的引用 / 它自己截的图**时,那些 /Users/…png 要变成缩略图,
802
+ 不能当文字念(2026-09-20 用户:「用户的截图显示对了 bot回复中显示图片还不对」)。
803
+ 没有截图路径时 qaShotsMount 内部就是 mdToHtmlOnly,语义与原来逐字相同。 */
804
+ const tmp = document.createElement("div");
805
+ qaShotsMount(tmp, chunk);
806
+ // 把 tmp 下的每个块级节点追加到当前正文块(而不是覆盖它,因为已存在历史段落)
807
+ [...tmp.childNodes].forEach(node=>{
808
+ // 去掉 streaming 类的 cursor,实际只加在最后一个未闭合段落
809
+ if(node.classList) node.classList.remove("streaming");
810
+ curText.appendChild(node);
811
+ });
812
+ });
813
+ // 重新上色本轮新加的代码块(避免重复:marked 已产出 .hljs,highlightIn 会检查 dataset.hl)
814
+ highlightIn(curText);
815
+ // 同步给 streaming 类(光标)只在最后一个未闭合 tail 上
816
+ const tail = curText.querySelector(".qa-tail");
817
+ if(tail) tail.classList.add("streaming");
818
+ }
819
+ function renderTail(){
820
+ // 渲染尾部未闭合段落:纯文本预览(div 块级容器,避免被 inline 上下文挤压)
821
+ if(!curText) return;
822
+ const old = curText.querySelector(".qa-tail");
823
+ if(old) old.remove();
824
+ if(!pending) return;
825
+ const d = document.createElement("div");
826
+ d.className = "qa-tail streaming";
827
+ d.textContent = pending;
828
+ curText.appendChild(d);
829
+ }
830
+ function rerenderAll(text){
831
+ /* result / turn_end 带的是**这一轮的权威全文**(引擎 result 行),此前用它整体覆盖正文,
832
+ 为的是「流式增量可能丢字符」时能纠正(主进程还会在这里剥掉拼在末尾的限流通告)。
833
+ ⚠️ 时间轴下的新口径(2026-10-12):**只有一个正文块时才覆盖** ——
834
+ 有 ≥2 块 = 正文被工具/思考打断过,权威全文没法按块还原(它不是分段的),
835
+ 强行覆盖只会把几段挤成一段、或把后段内容复制到前段。那种情况下保留流式分段,
836
+ 并留一条 console 记录(宁可少一次纠错,也不能画错)。
837
+ 写回的是**原来那一块**(位置不动)—— 权威全文不重排时间轴。 */
838
+ if(textBlocks.length > 1){
839
+ console.warn("[qaRenderAnswer] 正文被过程项打断(" + textBlocks.length + " 段),跳过权威全文覆盖");
840
+ return;
841
+ }
842
+ const body = curText || textBlocks[0] || ensureText();
843
+ if(curText === body) pending = "";
844
+ // 一次性解析(此时已知完整 markdown,无未闭合);走统一落笔处:正文里的截图路径一并变缩略图
845
+ body.innerHTML = "";
846
+ qaShotsMount(body, text);
847
+ highlightIn(body);
848
+ pending = "";
849
+ }
850
+
851
+ /* ===== 工具调用卡(Codex 体验)=====
852
+ toolCall(name, argsPreview, argsFull) → { done(resultText, ok), state }
853
+ · 运行中:实线卡片、spinner、秒数跳动,默认收起(点头部展开看输入/输出)
854
+ · done():状态切勾/叉、秒数定格、维持收起降调为静默单行(用户点开过则保持)
855
+ · 卡片**落在队尾**(按到达顺序)—— 时间轴下不再插到正文之前;正文在此封口,
856
+ 之后的正文另起一块(见 ensureText)。
857
+ 2026-09-17 重构:卡片本体抽成 mkCard(只造不插)—— toolCall(插主流)、
858
+ agentCall(委派卡)、subTool(嵌套进父委派卡)共用同一份结构,两处各写一份必然走形。
859
+ 2026-09-18:toolCall 按 description 分流 —— 带 description 的独立显示、不带 description 的归组
860
+ (用户:「调用工具有 description 时要独立出来显示,没有的才在一起一个组显示」)。 */
861
+ /* 卡片到达序号(2026-09-20):折叠组里的卡片按**到达顺序**排 —— 独立卡超编被收编进组时
862
+ 要插回它该在的位置(组里还夹着无 description 的卡,直接 append 会把它排到整组之后)。 */
863
+ let toolSeq = 0;
864
+ /* isDesc:摘要是不是「人话」(description / command)—— 是则换正文字体渲染(见 .qa-tool-args.qa-desc)。
865
+ 传布尔而不是靠调用方自己加类:mkCard 是唯一一处写这份标记的地方(委派卡/子工具卡都复用)。 */
866
+ function mkCard(name, argsPreview, argsFull, isDesc, meta){
867
+ const card = document.createElement("div");
868
+ card.className = "qa-tool";
869
+ card.dataset.state = "running";
870
+ card.dataset.seq = String(++toolSeq);
871
+ const started = Date.now();
872
+ /* 明细段统一成「代码面板帧」的形制(label 行 + 复制按钮 + 代码体,见 29-msg-variants
873
+ 的 qaCodeFrameNode 注释):这里原来是裸 `<pre>` 加一枚 9px 小标题 —— 没有复制、
874
+ 也不横向滚动,长输出只能靠拖选,与 anywhere 的 CodePanelFrame 差一档。
875
+ ⚠️ 复制按钮**不带监听**:点击走 #qa-stream 事件委托(07 的 bindQaStreamDelegation),
876
+ 否则快照恢复(`innerHTML = snapshot`)之后按钮就成了死的。 */
877
+ card.innerHTML = `
878
+ <div class="qa-tool-head">
879
+ <span class="qa-tool-ico">${QA_ICONS.tool}</span>
880
+ <span class="qa-tool-name"></span>
881
+ <span class="qa-tool-args"></span>
882
+ <span class="qa-tool-meta">
883
+ <span class="qa-tool-dur">0.0s</span>
884
+ <span class="qa-tool-st">
885
+ <svg class="i-ok" viewBox="0 0 24 24"><path d="M4 12.5l5 5L20 6.5"/></svg>
886
+ <svg class="i-err" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>
887
+ </span>
888
+ <svg class="chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
889
+ </span>
890
+ </div>
891
+ <div class="qa-tool-bd">
892
+ <div class="qa-tool-sec v-in-sec">
893
+ <div class="qa-sec-hd"><span class="k">输入</span>${QA_COPY_BTN}</div>
894
+ <pre class="v-in"></pre>
895
+ </div>
896
+ <div class="qa-tool-sec v-sec" hidden>
897
+ <div class="qa-sec-hd"><span class="k">输出</span>${QA_COPY_BTN}</div>
898
+ <pre class="v-out"></pre>
899
+ </div>
900
+ </div>`;
901
+ /* 人话标题(2026-09-28,对齐 anywhere 的 timelineToolTitle):调用方给了 meta.title 时
902
+ 行里不再顶「内部工具名 + 参数 JSON」,而是写一句人话(「运行了 ls -la」「编辑 a.ts」);
903
+ 原始名与参数降级进 title 属性(悬停可见)与明细的「输入」段(排障照旧拿得到)。
904
+ ⚠️ 没给 meta 的老调用方(委派卡 / 子代理卡 / 未配对结果)行为逐字不变 —— 这是新增的
905
+ 可选形参,不是替换。 */
906
+ const nameEl = card.querySelector(".qa-tool-name");
907
+ const argsEl = card.querySelector(".qa-tool-args");
908
+ if(meta && meta.title){
909
+ nameEl.textContent = "";
910
+ nameEl.hidden = true;
911
+ argsEl.textContent = String(meta.title);
912
+ argsEl.classList.add("qa-desc");
913
+ argsEl.title = String(name || "tool")
914
+ + (argsPreview || argsFull ? " · " + String(argsPreview || argsFull).slice(0, 300) : "");
915
+ const kind = String((meta && meta.kind) || "tool").replace(/[^a-z_]/gi, "") || "tool";
916
+ card.classList.add("qa-kind-" + kind);
917
+ card.querySelector(".qa-tool-ico").innerHTML = qaToolIconFor(kind);
918
+ }else{
919
+ nameEl.textContent = String(name || "tool");
920
+ argsEl.textContent = String(argsPreview || argsFull || "");
921
+ if(isDesc) argsEl.classList.add("qa-desc");
922
+ }
923
+ const vInText = String(argsFull || argsPreview || "");
924
+ if(vInText) card.querySelector(".v-in").textContent = vInText;
925
+ else card.querySelector(".v-in-sec").hidden = true;
926
+ const durEl = card.querySelector(".qa-tool-dur");
927
+ const timer = setInterval(()=>{
928
+ durEl.textContent = ((Date.now() - started) / 1000).toFixed(1) + "s";
929
+ }, 100);
930
+ // 开合点击走 #qa-stream 委托(见 bindQaStreamDelegation),此处不直接绑监听:
931
+ // 直接绑的监听在快照恢复(innerHTML 重建)后会丢,卡片就点不开了。
932
+ const api = {
933
+ card,
934
+ /* 追加一个明细段(文件变更行走这条,见 09 的 tool_use 分支)。
935
+ 插在「输出」段之前 → 段落顺序与 anywhere 的 ToolDetailPanel 一致:输入 / 变更 / 输出。 */
936
+ addSection(node){
937
+ if(!node) return null;
938
+ const bdEl = card.querySelector(".qa-tool-bd");
939
+ bdEl.insertBefore(node, card.querySelector(".v-sec"));
940
+ card.classList.remove("qa-tool-bare");
941
+ qaScrollIntoView();
942
+ return node;
943
+ },
944
+ done(resultText, ok = true){
945
+ clearInterval(timer);
946
+ card.dataset.state = ok ? "ok" : "err";
947
+ durEl.textContent = ((Date.now() - started) / 1000).toFixed(1) + "s";
948
+ if(resultText != null && String(resultText).length){
949
+ const outSec = card.querySelector(".v-sec");
950
+ const outPre = card.querySelector(".v-out");
951
+ const rt = String(resultText);
952
+ outSec.hidden = false;
953
+ /* 结果本身是 unified diff 时走 **Diff 面板**(2026-09-28)。
954
+ 引擎回执里常见「改完了 + 一段 diff 原文」;当普通文本摊开的话,
955
+ +/- 混在正文里既看不出增删也不好读。对齐 anywhere:diff 一律走 DiffPanel
956
+ (四列栅格 + 增删配色)。
957
+ ⚠️ 只在**确实像** diff 时才替换:判定要同时有 `@@` 与 `---`/`+++`(见 qaLooksLikeDiff),
958
+ 否则普通输出里以减号开头的行会被误判成一整块 diff。
959
+ ⚠️ 原文仍留在 pre 里(只是 hidden):复制按钮取的就是 `.qa-tool-sec pre` 的文本,
960
+ 挪走它复制按钮就瞎了。 */
961
+ outPre.textContent = rt;
962
+ const asDiff = typeof qaLooksLikeDiff === "function" && qaLooksLikeDiff(rt) && !outSec.querySelector(".qa-diff");
963
+ if(asDiff){
964
+ outPre.hidden = true;
965
+ const d = document.createElement("div");
966
+ d.className = "qa-diff";
967
+ d.innerHTML = qaDiffRowsHtml(qaDiffTextToRows(rt), true);
968
+ outSec.appendChild(d);
969
+ }
970
+ /* 工具产物缩略图(2026-10-14 用户:「工具生成了图片 应该显示缩略图」):
971
+ 输出里带着**落盘产物**路径时(job_output 的 `MEDIA: /Users/…/x.png`、
972
+ 成片 / 文档路径),光看一串路径不知道它到底产出了什么 —— 在输出段里把它们
973
+ 画成产物卡(图 → 缩略图,点开看原图;视频 / 音频 / 其它文件各就其位)。
974
+ ⚠️ 复用对话产物那条链(19-attachments::qaArtMount,与正文里的产物同一个口径):
975
+ 只**挑**不摘 —— 输出原文(pre)一个字不动,复制按钮照旧取得到全文;
976
+ 存在性由主进程探针判,预览态没有桥 → 一张不画,绝不会画出一枚必然裂掉的图。
977
+ ⚠️ 不跟 diff 抢:结果本身是 diff 时(outPre 已藏)没有产物可挑。
978
+ ⚠️ 已挂过就不再挂:收尾兜底(settleRunningTools)会在结果之后再来一次 done。 */
979
+ if(!asDiff && !outSec.querySelector(".qa-arts") && typeof qaArtMount === "function"){
980
+ qaArtMount(outSec, qaArtCollect(rt));
981
+ }
982
+ }
983
+ /* 「无明细」退化形态(对齐 anywhere:工具没有 command/output/changes 时退化成一条
984
+ 单行 marker,不带边框与底纹)。
985
+ 判据取**实际渲染后**的段内容而不是入参:多段拼装(文件变更行、追加段)之后,
986
+ 只有这时候才知道这条到底有没有东西可看。class 落在 HTML 上 → 快照可还原。 */
987
+ const secs = card.querySelectorAll(".qa-tool-bd > .qa-tool-sec");
988
+ let hasDetail = false;
989
+ for(const s of secs){
990
+ if(s.hidden) continue;
991
+ const pre = s.querySelector("pre");
992
+ if(pre ? pre.textContent.trim().length > 0 : s.childElementCount > 0){ hasDetail = true; break; }
993
+ }
994
+ card.classList.toggle("qa-tool-bare", !hasDetail);
995
+ if(card.dataset.touched !== "1") card.classList.remove("open");
996
+ /* 落定即收进本段的「已调用工具 · N个」折叠组(2026-10-12 用户:「历史的都应该显示在
997
+ 已调用里,只有最新的 1 个运行中显示在外面」)—— 外面只留还在跑的那张。
998
+ ⚠️ 放在最后:收编会把它移进组容器,之后再动卡上的节点(找 `.v-sec` 之类)会越过组。
999
+ agentCall 也走这条(它的 done 会 ref 到同一张卡,见那里的说明)。 */
1000
+ collapseIntoGroup(card);
1001
+ qaScrollIntoView();
1002
+ },
1003
+ get state(){ return card.dataset.state; },
1004
+ };
1005
+ /* 落定钩子挂在**节点**上(2026-09-18):流收尾要能把「配不到 tool_result id 的追加卡」
1006
+ 也一起收掉 —— 一条事件装多次调用时,只有第一张挂得上 id,其余原来会永远转圈
1007
+ (卡片从折叠组挪成独立卡后更显眼,所以这里补上)。挂 DOM 而不是闭包数组,
1008
+ 是因为收尾那一侧只有节点可拿;快照恢复出来的历史卡没有钩子 → 收尾自然跳过
1009
+ (它们本来就不是 running,扫不到)。 */
1010
+ card.__qaDone = api.done;
1011
+ return api;
1012
+ }
1013
+ /* 每调一次 = 一次调用(「调用了多少个工具」= 画了几张卡;一条事件里装了多次调用时,
1014
+ 调用方会按 description 切成多个单元逐个调进来,见 qaToolUnits)。
1015
+ ===== 显示口径(2026-10-12 用户:「历史的都应该显示在已调用里,只有最新的 1 个运行中显示在外面」)=====
1016
+ · **正在跑的那张留在外面**(.qa-tool-alone,卡片本体一眼可见,能看实时秒数);
1017
+ · **一落定就收进「已调用工具 · N个」折叠组**(见 mkCard.done → collapseIntoGroup)——
1018
+ 于是画布上任何时刻最多只剩一张「在跑」的卡,历史调用全部压成一条折叠行。
1019
+ ⚠️ 这取代了 2026-09-18 的「带 description 的独立显示 / 其余的归组」分流与
1020
+ 2026-09-20 的「正文只留最近 5 张」配额(ALONE_KEEP):那两条的判据都是
1021
+ **引擎写没写 description**,而用户要的是**按运行状态**收放,与文案无关。
1022
+ ⚠️ 组头懒点亮:第一张**收进组**的卡到达时才点亮(全在跑的段落不会留下一个 0个 的空组)。 */
1023
+ /* 进组:按到达序号插回该在的位置(组里夹着先跑完的卡,append 会打乱先后),
1024
+ 顺手计数 +1;组头若还没点亮(本段第一张落定的卡)则点亮 —— 已经定格过的组
1025
+ 保持「已调用工具」不变(晚到的收编不该把它翻回「工具调用中」)。 */
1026
+ function groupAdd(card, g){
1027
+ const t = g || ensureGroup();
1028
+ const seq = Number(card.dataset.seq || 0);
1029
+ const sib = [...t.body.children].find(el=> Number(el.dataset.seq || 0) > seq);
1030
+ if(sib) t.body.insertBefore(card, sib); else t.body.appendChild(card);
1031
+ if(!t.start) groupBegin(t); // 首次有成员 → 点亮这一段统计行
1032
+ t.num += 1;
1033
+ t.count.textContent = " · " + t.num + "个"; // 实时更新计数
1034
+ return t;
1035
+ }
1036
+ /** 落定即收进本段的折叠组:只认 toolCall / agentCall 落在**流里**的那些卡
1037
+ * (子代理内部卡 subTool 挂在父卡体内,不在流里)。幂等。
1038
+ * ⚠️ 归属组记在**节点属性**上(`__qaGroup`):这一笔跑完时 `curGroup` 可能早已换段清空,
1039
+ * 靠它才能回到**本段**那个组,而不是现建一个新组(那会把这张卡甩到整条流的末尾)。 */
1040
+ function collapseIntoGroup(card){
1041
+ if(!card || !card.__qaFlow || card.__qaCollapsed) return;
1042
+ card.__qaCollapsed = true;
1043
+ card.classList.remove("qa-tool-alone"); // 左细条是「在外面」的标记,进组就摘掉
1044
+ groupAdd(card, card.__qaGroup);
1045
+ }
1046
+ function toolCall(name, argsPreview, argsFull, isDesc, meta){
1047
+ const t = mkCard(name, argsPreview, argsFull, isDesc, meta);
1048
+ /* 先在外面(运行中),落定后由 mkCard.done → collapseIntoGroup 收进组。 */
1049
+ const g = ensureGroup(); // 本段工具组(组头等第一张落定的卡才点亮)
1050
+ sealThink(); sealText(); // 这一笔之后,正文/思考另起块
1051
+ t.card.classList.add("qa-tool-alone");
1052
+ t.card.__qaFlow = true;
1053
+ t.card.__qaGroup = g;
1054
+ bd.appendChild(t.card);
1055
+ qaScrollIntoView();
1056
+ return t;
1057
+ }
1058
+ /* ===== ⑤ 提问卡(AskUserQuestion)=====
1059
+ 2026-09-18 用户:「返回了 askuserquestion,需要 ui 在对话框中增加选择显示 而不是显示这个 json」。
1060
+ 引擎问「你想执行哪个操作」时,原来只落成一张普通工具卡:questions/options 的 JSON 摊在
1061
+ 「输入」里,用户得自己读 JSON 再手打答案。这里换成**可点选的卡**:
1062
+ · **答满即走**(2026-10-10 用户:「回答完应该自动关闭并提交答案继续对话」):
1063
+ 每一问都有选择 → 卡自己把答案发出去(随后自动撤下),**不必再点「提交」**
1064
+ —— 全选中的卡立即发,带可多选那一问的卡停手 1.5s 发(见 06-ask-answer.js::qaAskAutoArm);
1065
+ · 「其他」行 → 想选不在清单里的答案就直接写,回车/点确认即答。
1066
+ 参数 JSON 原样留在卡的 title 上(要排障时鼠标停一下就能看到),但不再摊在卡面上。
1067
+ 解析失败时调用方不会走到这里(回落普通工具卡),所以这里 qs 至少有 1 问。
1068
+ ⚠️ 2026-09-30(用户:「确认框不应该显示在消息中 需要显示在对话框上」):这张卡**不再插进
1069
+ 对话流**,改挂到输入框正上方的停靠位 `#qa-askdock`(见 06-ask-answer.js 的「提问卡停靠位」)。
1070
+ 卡上因此多了一枚 ×(把它从停靠位撤下 = 不回答,卡不该永远占着输入框上方那块地方)。 */
1071
+ /* qs = 归一化后的问题数组。两种来源共用这一种结构:
1072
+ · tool_use 参数 → qaAskParse(要猜三种不稳定形状,老版 CLI 只有这条路)
1073
+ · 统一 ask 事件 → qaAskFromEvent(magic-agent 2026-09-18 起归一化过的稳定形状)
1074
+ opts = { title, sid, rid }:title 是排障用的原始载荷;sid / rid = **答案要回到哪条会话**
1075
+ (追加通道的寻址信息,落 dataset,见下)。 */
1076
+ function askCard(name, qs, opts){
1077
+ const list = Array.isArray(qs) ? qs : [];
1078
+ const o = opts || {};
1079
+ const multi = list.length > 1 || list.some(q=>q.multiSelect);
1080
+ /* 收笔那枚「提交」钮**只在卡里有可多选的问时才画**(2026-10-10):单选是明确的落笔,
1081
+ 答满即走(见 06-ask-answer.js::qaAskAutoArm),摆一枚用不上的钮只会让人以为还差一步;
1082
+ 而「勾了几项算完」没有别的信号 —— 那一问必须由用户点一下收笔(卡上那行提示照实说)。 */
1083
+ const needSubmit = list.some(q=>q.multiSelect);
1084
+ const card = document.createElement("div");
1085
+ card.className = "qa-ask";
1086
+ card.title = String(o.title || "").slice(0, 600);
1087
+ /* 答案要回到哪条会话 —— 落在 **dataset** 上而不是闭包里:
1088
+ 快照恢复走 `innerHTML = snapshot`,闭包随旧节点一起丢,dataset 跟着 HTML 活下来,
1089
+ 所以恢复出来的历史提问卡照样答得回去(REQ-22 锁的就是「已答态随快照恢复」)。 */
1090
+ if(o.sid) card.dataset.sid = String(o.sid);
1091
+ if(o.rid != null && o.rid !== "") card.dataset.rid = String(o.rid);
1092
+ card.innerHTML = `
1093
+ <div class="qa-ask-hd">
1094
+ <span class="qa-ask-ico">${QA_ICONS.ask}</span>
1095
+ <span class="qa-ask-tag"></span>
1096
+ <span class="qa-ask-ttl">提问 · 待回答</span>
1097
+ <button type="button" class="qa-ask-x" title="收起这个确认框(不回答)" aria-label="收起这个确认框">×</button>
1098
+ </div>
1099
+ <div class="qa-ask-list"></div>
1100
+ <div class="qa-ask-other">
1101
+ <input type="text" placeholder="其他:不在上面选项里就直接写,回车确认">
1102
+ <button type="button" class="qa-ask-go">确认</button>
1103
+ </div>
1104
+ <div class="qa-ask-ft"${multi ? "" : " hidden"}>
1105
+ ${needSubmit ? '<button type="button" class="qa-ask-sub" disabled>提交</button>' : ""}
1106
+ <span class="qa-ask-hint">${needSubmit
1107
+ ? (list.length > 1 ? "共 " + list.length + " 问 · 可多选,勾完自动提交(也可点提交)" : "可多选,勾完自动提交(也可点提交)")
1108
+ : "共 " + list.length + " 问,都选完自动提交"}</span>
1109
+ </div>
1110
+ <div class="qa-ask-ans" hidden><span class="k">你的回答</span><span class="v"></span></div>`;
1111
+ /* ⚠️ 2026-09-22 审美修订:这枚标签原来**直接把引擎的工具名画在卡头上**
1112
+ (`AskUserQuestion`,9px 等宽 + 蓝框)—— 用户看到的是英文实现细节,
1113
+ 读不出「这张卡是在等我回答」。现在标签写人话「需要你确认」,
1114
+ 工具名降级进 title(排障时鼠标一悬停仍能看到是哪个工具)。 */
1115
+ const tagEl = card.querySelector(".qa-ask-tag");
1116
+ if(tagEl){
1117
+ tagEl.textContent = "需要你确认";
1118
+ tagEl.title = "引擎提问工具:" + String(name || "AskUserQuestion");
1119
+ }
1120
+ const listEl = card.querySelector(".qa-ask-list");
1121
+ list.forEach((q, qi)=>{
1122
+ const qb = document.createElement("div");
1123
+ qb.className = "qa-ask-qb";
1124
+ qb.dataset.qi = String(qi);
1125
+ qb.dataset.multi = q.multiSelect ? "1" : "0";
1126
+ qb.dataset.q = q.question;
1127
+ const head = document.createElement("div");
1128
+ head.className = "qa-ask-q";
1129
+ if(q.header){
1130
+ const chip = document.createElement("span");
1131
+ chip.className = "qa-ask-chip";
1132
+ chip.textContent = q.header;
1133
+ head.appendChild(chip);
1134
+ }
1135
+ head.appendChild(document.createTextNode(q.question));
1136
+ qb.appendChild(head);
1137
+ const opts = document.createElement("div");
1138
+ opts.className = "qa-ask-opts";
1139
+ q.options.forEach((o)=>{
1140
+ const b = document.createElement("button");
1141
+ b.type = "button";
1142
+ b.className = "qa-ask-opt";
1143
+ b.dataset.label = o.label;
1144
+ b.setAttribute("role", q.multiSelect ? "checkbox" : "radio");
1145
+ b.setAttribute("aria-checked", "false");
1146
+ b.innerHTML = `<span class="mk"><svg viewBox="0 0 24 24"><path d="M4 12.5l5 5L20 6.5"/></svg></span>
1147
+ <span class="tx"><span class="lb"></span><span class="ds"></span></span>`;
1148
+ b.querySelector(".lb").textContent = o.label;
1149
+ b.querySelector(".ds").textContent = o.description;
1150
+ if(!o.description) b.querySelector(".ds").remove();
1151
+ opts.appendChild(b);
1152
+ });
1153
+ qb.appendChild(opts);
1154
+ listEl.appendChild(qb);
1155
+ });
1156
+ /* 2026-09-30:卡**不进对话流**,改挂输入框上方的停靠位(见 06-ask-answer.js「提问卡停靠位」)——
1157
+ 用户:「确认框不应该显示在消息中 需要显示在对话框上」。
1158
+ 归属会话由**渲染宿主**反推:可见画布 s 就是当前这条;后台 run 的 holder 由 qaAskDockTaskOf 认领
1159
+ (这条会话切到前台时,卡会跟着搬回停靠位,不会落进对话流)。 */
1160
+ if(typeof qaAskDockAdd === "function") qaAskDockAdd(card, qaAskDockTaskOf(s));
1161
+ else { sealAll(); bd.appendChild(card); } // 兜底:停靠位没加载时才落进对话流(队尾)
1162
+ let settled = false; // 这一问是否已被引擎收尾(见 done)
1163
+ return {
1164
+ card,
1165
+ /* 引擎这一问的收尾(tool_result / 流结束兜底)。**卡不会因此失效**:
1166
+ 多数引擎在非交互模式下收不到回答就自己结束了,此时用户点选项仍然是把答案
1167
+ 作为追问发回去 —— 那正是这张卡存在的意义,所以这里只更新标题、不禁用。 */
1168
+ done(){
1169
+ if(!qaAskAnswered(card)){
1170
+ const ttl = card.querySelector(".qa-ask-ttl");
1171
+ if(ttl) ttl.textContent = "提问 · 待回答";
1172
+ }
1173
+ settled = true;
1174
+ qaScrollIntoView();
1175
+ },
1176
+ /* 统一 ask 事件后到(magic-agent 会为同一问先发 tool_use、再发 ask)→ 不重画第二张卡,
1177
+ 只把「答回去要用的东西」补到这张已有的卡上(sid/rid,见 qaStreamApplyOne 的 ask 分支)。 */
1178
+ bindAsk(o2){
1179
+ const s2 = (o2 && o2.sid) || "";
1180
+ if(s2) card.dataset.sid = String(s2);
1181
+ if(o2 && o2.rid != null && o2.rid !== "") card.dataset.rid = String(o2.rid);
1182
+ },
1183
+ get state(){ return settled ? "ok" : "running"; },
1184
+ };
1185
+ }
1186
+ /* ① 子代理委派卡(.qa-tool.qa-agent):工具卡变体 + 状态胶囊 + 嵌套活动区。
1187
+ 生命周期由 ② 的 system 事件驱动(start/progress/notify 只改胶囊);
1188
+ 最终结果仍按 tool_use id 配对写进「输出」(done)—— 没等到 task_notification
1189
+ 就收账时,胶囊在 done 里一并落定,不会留下常驻转圈。 */
1190
+ function agentCall(name, argsPreview, argsFull, promptText){
1191
+ // 委派卡的摘要本来就是 description(见调用处的 desc 取值)→ 按人话渲染
1192
+ const t = mkCard(name, argsPreview, argsFull, true);
1193
+ const card = t.card;
1194
+ card.classList.add("qa-agent");
1195
+ card.querySelector(".qa-tool-ico").innerHTML = QA_ICONS.agent;
1196
+ const chip = document.createElement("span");
1197
+ chip.className = "qa-agent-chip";
1198
+ chip.textContent = "等待启动";
1199
+ card.querySelector(".qa-tool-meta").prepend(chip);
1200
+ const bdEl = card.querySelector(".qa-tool-bd");
1201
+ if(promptText != null && String(promptText).length){
1202
+ const sec = document.createElement("div");
1203
+ sec.className = "qa-tool-sec";
1204
+ /* 段头与「输入 / 输出」同一形制(label + 复制按钮):任务词是这条委派里最该被复制走的
1205
+ 东西(用户常要拿它去别处复用),而它原来连复制入口都没有。 */
1206
+ sec.innerHTML = `<div class="qa-sec-hd"><span class="k">任务词</span>${QA_COPY_BTN}</div><pre></pre>`;
1207
+ sec.querySelector("pre").textContent = String(promptText);
1208
+ bdEl.insertBefore(sec, bdEl.firstChild); // 任务词置顶(比原始参数 JSON 更可读)
1209
+ }
1210
+ const sub = document.createElement("div");
1211
+ sub.className = "qa-sub";
1212
+ sub.innerHTML = `<div class="qa-sub-h">子代理活动</div>`;
1213
+ bdEl.appendChild(sub);
1214
+ /* 委派卡是里程碑:正文/思考在这一笔处封口,但**不封工具段** —— 它仍属于这一段的
1215
+ 「内部活动」,与前后的工具卡同处一段。**运行中留在外面**(卡里挂着子代理的实时活动,
1216
+ 藏进折叠组等于把正在跑的委派藏起来);**落定后同样收进「已调用工具 · N个」**
1217
+ (2026-10-12 口径:历史都进组,外面只留还在跑的那张 —— 见 collapseIntoGroup)。 */
1218
+ const g = ensureGroup();
1219
+ sealThink(); sealText();
1220
+ card.__qaFlow = true;
1221
+ card.__qaGroup = g;
1222
+ bd.appendChild(card);
1223
+ qaScrollIntoView();
1224
+ const api = {
1225
+ done(resultText, ok = true){
1226
+ t.done(resultText, ok);
1227
+ if(chip.classList.contains("run")){ // 通知缺失时兜底落定(完成 → jade 绿)
1228
+ chip.classList.remove("run");
1229
+ if(ok) chip.classList.add("ok");
1230
+ chip.textContent = ok ? "完成" : "未完成";
1231
+ }
1232
+ },
1233
+ get state(){ return t.state; },
1234
+ start(){ chip.textContent = "运行中"; chip.classList.add("run"); },
1235
+ progress(label){ if(chip.classList.contains("run")) chip.textContent = String(label || "进行中"); },
1236
+ notify(label, ok = true){
1237
+ chip.classList.remove("run");
1238
+ if(ok) chip.classList.add("ok");
1239
+ chip.textContent = String(label || (ok ? "完成" : "已结束"));
1240
+ },
1241
+ subTool(nm, pv, full, isDesc){ // ③ 子代理内部工具 → 嵌套卡(样式更紧凑)
1242
+ const st = mkCard(nm, pv, full, isDesc);
1243
+ st.card.classList.add("qa-tool-sub");
1244
+ sub.appendChild(st.card);
1245
+ qaScrollIntoView();
1246
+ return st;
1247
+ },
1248
+ subText(txt){ // ③ 子代理正文 → 嵌套区文本行
1249
+ const el = document.createElement("div");
1250
+ el.className = "qa-sub-t";
1251
+ el.textContent = String(txt || "");
1252
+ sub.appendChild(el);
1253
+ qaScrollIntoView();
1254
+ },
1255
+ subNote(txt){ // ③ 子代理思考/回执 → 嵌套区注脚
1256
+ const el = document.createElement("div");
1257
+ el.className = "qa-sub-n";
1258
+ el.textContent = String(txt || "");
1259
+ sub.appendChild(el);
1260
+ qaScrollIntoView();
1261
+ },
1262
+ subImage(uri){ // ③ 子代理产出的图 → 嵌套区图片
1263
+ const img = document.createElement("img");
1264
+ img.className = "qa-img";
1265
+ img.src = String(uri || "");
1266
+ img.alt = "图片";
1267
+ sub.appendChild(img);
1268
+ qaScrollIntoView();
1269
+ },
1270
+ };
1271
+ /* 委派卡也要能被流收尾兜底落定(胶囊 + 卡状态一起)→ 覆盖 mkCard 挂的那个钩子 */
1272
+ card.__qaDone = api.done;
1273
+ return api;
1274
+ }
1275
+ /* ② 无主系统事件的降级行(父委派卡缺席时)。
1276
+ 2026-09-28 起改走**单行标记行**(29-msg-variants 的 qaMarkerNode):原来只是一行 10px
1277
+ 灰字(`.qa-note`),事件里带的 JSON 与状态**全丢**;现在同一格既能给纯文本注脚,
1278
+ 也能给「图标 + 标题 + 状态徽章 + 可展开明细」的完整形态(对齐 anywhere 的 Marker/JsonMarker)。
1279
+ ⚠️ 保留 note(text) 这个出口且签名不变:13-dispatch-stream / 24-agent-test 等旧调用方还在用,
1280
+ 它们自动升格成标记行(信息只多不少)。 */
1281
+ function markerNode(opts){
1282
+ const n = qaMarkerNode(opts);
1283
+ sealAll(); // 标记行是横切事件:三类块一起封口
1284
+ bd.appendChild(n);
1285
+ qaScrollIntoView();
1286
+ return n;
1287
+ }
1288
+ function note(text, opts){
1289
+ return markerNode(Object.assign({ title: String(text || "") }, opts || {}));
1290
+ }
1291
+ /* ④ 分隔条(横切整条会话的事件,如上下文压缩边界) */
1292
+ function sepNode(text, opts){
1293
+ const n = qaSepNode(text, opts);
1294
+ sealAll();
1295
+ bd.appendChild(n);
1296
+ qaScrollIntoView();
1297
+ return n;
1298
+ }
1299
+ /* ⑥ 任务列表(todo-row):**一条应答里只有一块**,反复调用原地更新。
1300
+ 为什么合并:TodoWrite / update_plan 每推进一小步就把**整份清单**重发一次 ——
1301
+ 一次调用一张工具卡,会让同一份清单在画布上重复出现 N 次(实测 5 次调用 = 5 份清单),
1302
+ 而用户要看的是「现在做到第几步」。合成一块之后,每次调用换的都是这块的内容。
1303
+ 落位:当前正文块之前(与「深度思考 / 工具调用 / 任务列表 / 正文」同一列序);
1304
+ 默认展开,点标题栏那枚箭头收起。
1305
+ ⚠️ 节点只建一个(`div.querySelector` 取回已有的那块),第二次起只换内容不新建。
1306
+ ⚠️ 这里**不绑监听**:开合态由 qaTodoFill 落类(29-msg-variants)、点击走
1307
+ #qa-stream 上的事件委托(宿主侧的 bindQaStreamDelegation)——
1308
+ 直接用 addEventListener 绑在这块上,画布快照恢复(innerHTML 重建)后就点不动了。
1309
+ ⚠️ 它不是「过程块」:`.qa-todo-panel` 不进段壳、也不进收尾那枚「任务耗时」大组
1310
+ (segFold / wrapTurnIn 只认 `.qa-think` / `.qa-tools`),所以跑完仍看得见。 */
1311
+ function todoList(rows){
1312
+ let host = div.querySelector(".qa-todo-panel");
1313
+ /* 空清单(引擎显式清空 / 这一版参数里没有可解析的条目):已有那块**如实摘掉**,
1314
+ 不留着上一份的残影 —— 「清单空了」与「还停在上一步」是两回事。 */
1315
+ if(!rows || !rows.length){
1316
+ if(host && host.parentNode) host.parentNode.removeChild(host);
1317
+ return null;
1318
+ }
1319
+ if(!host){
1320
+ host = qaTodoNode(rows, { title: "任务列表" });
1321
+ host.classList.add("open"); // 默认展开
1322
+ bd.insertBefore(host, curText || ensureText());
1323
+ }else{
1324
+ qaTodoFill(host, rows, { title: "任务列表" });
1325
+ }
1326
+ qaScrollIntoView();
1327
+ return host;
1328
+ }
1329
+
1330
+ /* ===== 「任务耗时」分组:一轮收尾时,把这一轮的**历史过程**并进一枚折叠组 =====
1331
+ (2026-10-12 用户:「历史的思考和工具最终又合并在一个任务总耗时的分组里」,形制对齐 TraeWork)
1332
+ · 收的是**思考块 + 工具组**(`.qa-think` / `.qa-tools`),正文留在外面 —— 那是结论,过程才是历史;
1333
+ · **默认收起**(TraeWork 的形态:跑完只留一条「任务耗时 X」,要回看再点开);
1334
+ · 组落在**第一条过程块原来的位置**上,正文的相对次序因此不变;
1335
+ · 用原生 `<details>`:零脚本、键盘可达,开合态天然写进 HTML(快照往返不丢)。
1336
+ · ⚠️ 时长取**墙上时钟**(消息创建 → 收尾),与收尾行那段「· 总耗时 X」同一量;
1337
+ 回放的轮次(`__qaReplay`)用它自己的 `__qaMs`(会话日志的事件自带 `at` 墙钟,
1338
+ 回放按「本轮首条事件 → 收尾」算真实间隔;拿不到就不写时间,见 27-conv-log 的 qaSessionReplay),
1339
+ **不编 0.0s**。
1340
+ · 一条过程块都没有的轮(纯问答)不建这个组,免得凭空多一行。
1341
+ ⚠️ 按**容器**各并一枚(2026-10-15):思考 / 工具落在答复那条的 `.qa-bd`,而**机器侧那条**
1342
+ 的(审核轮 / 数字分身,被 qaTraceToLeft 搬进它自己的 `.qa-bd`)原来一枚都并不到 ——
1343
+ 那两档的历史里就永远是一长串「已深度思考 / 已调用工具」(与用户这次的报障同形)。 */
1344
+ function wrapTurnIn(host){
1345
+ if(!host || host.__qaTurn) return host ? host.__qaTurn : null; // 幂等:收尾可能被调用多次
1346
+ /* 合并 = 把**所有段**与没被段包住的散块(兜底)搬进一枚大组,成员**按 DOM 先后**排
1347
+ (段壳按位置展开成它的成员),于是组里仍是真实的时间顺序。 */
1348
+ const members = [], shells = [];
1349
+ [...host.children].forEach((n)=>{
1350
+ if(n.classList.contains("qa-turn")){ // 段壳 → 展开
1351
+ members.push(...n.querySelectorAll(":scope > .qa-turn-bd > *"));
1352
+ shells.push(n);
1353
+ }else if(n.classList.contains("qa-think") || n.classList.contains("qa-tools")){
1354
+ members.push(n);
1355
+ shells.push(n);
1356
+ }
1357
+ });
1358
+ if(!members.length) return null;
1359
+ const dur = qaDurText(div.__qaReplay ? div.__qaMs : (Date.now() - (div.__qaT0 || Date.now())));
1360
+ const d = document.createElement("details");
1361
+ d.className = "qa-turn";
1362
+ d.innerHTML = `<summary class="qa-turn-sum"><span class="qa-turn-t"></span><span class="qa-turn-n"></span></summary><div class="qa-turn-bd"></div>`;
1363
+ d.querySelector(".qa-turn-t").textContent = "任务耗时" + (dur ? " " + dur : "");
1364
+ /* ⚠️ 数的是「过程项」而不是「工具调用数」:思考块 + 工具组各算一项,与组里那行
1365
+ 「已调用工具 · N个」不是一回事(那个数是卡)。这里只给一个「收了几块」的量。 */
1366
+ d.querySelector(".qa-turn-n").textContent = members.length + " 项过程";
1367
+ const body = d.querySelector(".qa-turn-bd");
1368
+ host.insertBefore(d, shells[0]); // 落在**第一条过程**的位置(正文的相对次序不变)
1369
+ members.forEach((p)=> body.appendChild(p)); // 搬节点,不重建(计时器 / 计数引用都还在)
1370
+ shells.forEach((n)=>{ if(n.classList.contains("qa-turn")) n.remove(); }); // 段壳已空 → 撤掉
1371
+ host.__qaTurn = d;
1372
+ return d;
1373
+ }
1374
+ function wrapTurn(){
1375
+ sealSeg(); // 尾段(还没有正文来打断的那一段)先并
1376
+ const a = wrapTurnIn(bd);
1377
+ const b = wrapTurnIn(segHosts()[1] || null); // 机器侧那条(有才并)
1378
+ segs.length = 0; segT0 = 0;
1379
+ div.__qaTurn = a || b;
1380
+ return a || b;
1381
+ }
1382
+
1383
+ return {
1384
+ el: div, // 这条消息的根节点(消费方要给整条消息加形态时用:
1385
+ // 审查轮那条「审核引擎的回复」= 加个 qa-engine(换「审」头像 + 抬头),见 13-dispatch-stream)
1386
+ /* ⚠️ `think` / `body` 都是**取当前那一块**(时间轴下每段各一块,见文件头的块说明):
1387
+ `body` 取不到时会按需新建一块正文(24-agent-test 的卡片就靠它写整块内容)。 */
1388
+ get think(){ return curThink ? curThink.el : null; },
1389
+ get body(){ return curText || ensureText(); },
1390
+ beginThinking(){ thinkBegin(); },
1391
+ endThinking(){ thinkEnd(); },
1392
+ beginTools(){ groupBegin(); },
1393
+ endTools(){ groupEnd(); },
1394
+ appendThinking(t){ const c = thinkBegin(); c.body.textContent += t; },
1395
+ appendText(t){
1396
+ ensureText(); // 没有当前正文块就按到达顺序新起一块
1397
+ pending += t;
1398
+ flushClosed();
1399
+ renderTail();
1400
+ },
1401
+ /** 图片块:KindImage / 原生 image 块 → data URI 直接上正文区。
1402
+ * 图算**正文的一部分**(不是过程块):归到当前正文块、插在打字预览(.qa-tail)之前 ——
1403
+ * 与流式正文同一位置约定「已渲染段落之后、光标之前」,图后到的文字自然排在图下。
1404
+ * ⚠️ 与快照/权威全文同命运:只有一个正文块时 rerenderAll 会整体覆盖这一块,图随之一并重来
1405
+ * (权威全文里本来就带 base64 块,见宿主侧的事件归一)。 */
1406
+ appendImage(uri){
1407
+ if(!uri) return;
1408
+ ensureText();
1409
+ const fig = document.createElement("figure");
1410
+ fig.className = "qa-img";
1411
+ const img = document.createElement("img");
1412
+ img.src = String(uri);
1413
+ img.alt = "生成的图片";
1414
+ fig.appendChild(img);
1415
+ const tail = curText.querySelector(".qa-tail");
1416
+ if(tail) curText.insertBefore(fig, tail); else curText.appendChild(fig);
1417
+ qaScrollIntoView();
1418
+ },
1419
+ setResult(text){ rerenderAll(text); },
1420
+ toolCall,
1421
+ agentCall,
1422
+ askCard, // ⑤ 提问卡(AskUserQuestion → 可点选项)
1423
+ todoList, // ⑥ 任务列表(TodoWrite / update_plan → 一条应答只有一块,原地更新)
1424
+ note,
1425
+ marker: markerNode, // ② 单行标记行(图标 + 人话标题 + 状态 + 可展开明细)
1426
+ sep: sepNode, // ④ 分隔条(横切整条会话的事件)
1427
+ finish(){
1428
+ // 流式结束:把尾部 pending 也按 markdown 解析一次(行内标记如 ** / ` / []() 此时闭合完整)
1429
+ /* 先兜底落定还在转圈的卡 → 它们会就地收编进本段的折叠组;
1430
+ 再定格组头/思考块 —— 顺序反了的话,收编会往一个已经「已调用工具」定格的组里加卡
1431
+ (组头的秒数也就停早了)。 */
1432
+ settleRunningTools(true); // 还在转圈的卡一律落定(配不到回执的追加卡 / 点停止那条路)
1433
+ settleGroups(); // 收**所有**还亮着的工具组统计行(不只 curGroup,见 settleGroups)
1434
+ thinkEnd(); // 收思考块(停表 + 定格)
1435
+ sealText(); // 尾部落成真块(与旧 finish 同一条落笔处)
1436
+ /* 光标/尾部收干净:每块都摘掉 streaming 与残留的 .qa-tail
1437
+ (换过块时旧的 tail 已在 commitTail 里清掉,这里是兜底)。 */
1438
+ textBlocks.forEach(b=>{
1439
+ b.classList.remove("streaming");
1440
+ const tl = b.querySelector(".qa-tail"); if(tl) tl.remove();
1441
+ });
1442
+ /* 操作行显形(2026-10-09,与上面那两行同一时机 = 「这一轮的正文定型了」):
1443
+ 无论走的是正常收尾 / 出错 / 点停止,只要 finish 被调到,复制与重试就该可用。
1444
+ ⚠️ 六个收尾路径统一走 qaRunEnd → h.finish(),所以放在这里一处即全覆盖。 */
1445
+ acts.hidden = false;
1446
+ /* 最后一步:把这一轮的**历史过程**(思考块 + 工具组)并进一枚「任务耗时 X」折叠组
1447
+ (2026-10-12 用户:「历史的思考和工具最终又合并在一个任务总耗时的分组里」)。
1448
+ ⚠️ 必须放在所有过程块都定格之后:收编(collapseIntoGroup)也往工具组里搬卡,
1449
+ 先包就把后面落定的卡挡在组外了。 */
1450
+ wrapTurn();
1451
+ /* 最后一步:收尾一落闸就**粘住**(见 closed)—— 这一轮之后再到的事件(onDone 里等会话产物
1452
+ 那段最多 1.5s 的窗口、迟到的 tool_result 收编等)一律按定格态落笔,别再点亮出新的
1453
+ 「工具调用中」。放在最后是刻意的:上面的落定与封组仍按原路走,秒数照旧算得准。 */
1454
+ closed = true;
1455
+ },
1456
+ run,
1457
+ /** 改「生成中」胶囊上的文字。
1458
+ * 为什么要它(2026-09-21):胶囊初值是渲染层按**链首**拼的(`qaModel`,见 04-task-list.js),
1459
+ * 而这一轮真正用哪个模型是**主进程**按配置界面的降级链定的 —— 前一个没额度就换下一个重试
1460
+ * (同一个 rid 会再推一条 `start`,带上实际模型)。没有这个出口,界面就会写着 A、实际跑的是 B。
1461
+ * ⚠️ 只动胶囊文字,不重建节点(重建会打断 `.qa-run` 的转圈与裙边,见 verify-dark-chat 的断言)。 */
1462
+ setRunLabel(t){
1463
+ const el = run.querySelector(".qa-run-t");
1464
+ if(el) el.textContent = String(t || "");
1465
+ },
1466
+ /** 改头像右边的**引擎 · 模型小标**(默认是渲染时按当前引擎挂的,见 qaRenderAnswer)。
1467
+ * 说话人不是当前引擎时用(派发轮 = 该需求的引擎 / 模型;被测引擎 = 事件里的引擎)。
1468
+ * 引擎传空串 = 摘掉小标(宁可没有,不写「未知」);模型空 = 只显示引擎名。
1469
+ * ⚠️ `modelOnly`(2026-10-20):头像那一行并着「运行中 · 耗时计时」,
1470
+ * 「引擎 · 模型」整串太长会把秒数挤到换行(见 qaWhoLabel 的 modelOnly)。 */
1471
+ setWho(engine, model, modelOnly){
1472
+ const ico = div.querySelector(".qa-ico"); if(!ico) return;
1473
+ const lb = qaWhoLabel(engine, model, modelOnly);
1474
+ let w = ico.querySelector(".qa-who");
1475
+ if(!lb){ if(w) w.remove(); return; }
1476
+ if(!w){ w = document.createElement("span"); w.className = "qa-who"; ico.appendChild(w); }
1477
+ w.textContent = lb;
1478
+ },
1479
+ };
1480
+ }
1481
+ /** 把「机器内部活动」搬到**前面那条独立消息**里 —— 审核引擎与数字分身的「深度思考 / 工具调用」。
1482
+ *
1483
+ * 用户 2026-09-22 原话:「审核和数字分身自己的思考和工具调用要显示在左边 但是代表用户身份追问的
1484
+ * 消息要显示在右边」。上一版只在**答复那条消息内部**把这两块靠到左边(`app.css` 的 M4),
1485
+ * 用户看过后仍要「显示在左边」—— 所以这里升级成**真的搬到一条独立消息**:那条的语义是
1486
+ * 「机器在动」(与 bot 答复同一条左边线、头像也在左),另一条才是「它说了什么」。
1487
+ *
1488
+ * ⚠️ **搬节点,不重建**:思考块 / 工具组(`.qa-think` / `.qa-tools`)都是**带着句柄引用**的
1489
+ * (计时器、计数、展开体),重建会让流式期间的「思考中 · X.Xs」「工具调用 · N个」全部失联
1490
+ * (表现为数字不再跳)。搬完引用依旧有效 —— 这正是「思考 / 工具」能跟着引擎实时更新、
1491
+ * 而外层换了个容器也不受影响的原因。
1492
+ * ⚠️ 时间轴下**每段各一块**(2026-10-12):这里搬的可能是**多块**(全部 querySelectorAll),
1493
+ * 且**即使当前一块都没有也要把壳建出来** —— 之后新起的机器块由 qaRenderAnswer 的
1494
+ * machineHost() 直接落进这条(旧实现预置了空的思考块/工具组,恰好掩盖了「没壳就落错地方」)。
1495
+ * ⚠️ 新消息**先 hidden**:这两块在首条思考 / 首次工具调用之前是隐藏的,空着的一条消息会白占高度。
1496
+ * 显形由 `thinkBegin` / `groupBegin` 里那两行 `__traceWrap.hidden = false` 负责(同文件上方)。
1497
+ * ⚠️ 头像与答复那条**同一个**(`分` / `审`):机器的活动属于**它自己**;
1498
+ * 两枚头像的样式都是自包含的(`.qa-av.qa-av-sim` / `.qa-av-txt.qa-av-rev` 自带底色与字色),
1499
+ * 所以直接拷 ico 的 innerHTML 就与答复那条长得一模一样。
1500
+ * ⚠️ **2026-09-30 之后这条搬移只剩「机器 / 说话人」这一层意义**:审核引擎的回复也改到左边了
1501
+ * (用户:「审核消息应该显示在左边 左对齐不是右对齐」),不再靠左右分「审核 / 执行者」;
1502
+ * 数字分身的卡片那条仍在右(它代表用户身份那一侧,见 `atcCardStart`),搬移对它照旧是「机器在左」。
1503
+ * ⚠️ **历史快照**里这两块仍在原消息里(存的是当时的 HTML)—— 那条路由 `app.css` 的 M4(`:has()`)
1504
+ * 兜住:数字分身卡片那路仍生效;审核那两档已整条在左边,本就无需兜底。 */
1505
+ function qaTraceToLeft(h, engine, model){
1506
+ const el = h && h.el;
1507
+ if(!el || !el.parentNode) return null;
1508
+ if(el.__traceWrap) return el.__traceWrap; // 幂等:同一轮里重复调用只建一次
1509
+ const wrap = document.createElement("div");
1510
+ wrap.className = "qa-msg qa-trace";
1511
+ wrap.hidden = true; // 机器块都还藏着 → 整条不占位
1512
+ wrap.innerHTML = `<div class="qa-ico"></div><div class="qa-bd"></div>`;
1513
+ const tico = wrap.querySelector(".qa-ico");
1514
+ const ico = el.querySelector(".qa-ico");
1515
+ if(ico) tico.innerHTML = ico.innerHTML;
1516
+ /* 头像右边挂**机器侧自己的引擎 · 模型**:先摘掉从答复那条拷来的旧 `.qa-who`
1517
+ (答复那条若是左侧形状会自带一枚,那是说话人的,不是这台机器的),再按 engine/model 重挂。 */
1518
+ const oldWho = tico.querySelector(".qa-who"); if(oldWho) oldWho.remove();
1519
+ tico.insertAdjacentHTML("beforeend", qaWhoHtml(engine, model));
1520
+ const tbd = wrap.querySelector(".qa-bd");
1521
+ /* 已经长出来的机器块搬过去(每段一块,可能不止一块);之后新起的由 machineHost() 直接落这边。
1522
+ ⚠️ 过程中并出的**段壳**(`.qa-turn[data-seg]`)整只搬走 —— 只挑 `.qa-think` / `.qa-tools`
1523
+ 会把段里的成员留在答复那条里(它们已经不是 `.qa-bd` 的直系子节点了,见 sealSeg)。 */
1524
+ [...el.querySelectorAll(".qa-bd > .qa-think, .qa-bd > .qa-tools, .qa-bd > .qa-turn")].forEach((p) => tbd.appendChild(p));
1525
+ el.parentNode.insertBefore(wrap, el); // 排在答复那条**之前** = 机器先说「我在动」、再是「我说了什么」
1526
+ el.__traceWrap = wrap; // 显形由 thinkBegin / groupBegin 摘 hidden;machineHost() 也认它
1527
+ return wrap;
1528
+ }
1529
+ /** 总耗时文案(用户 2026-10-10:「对话也在结束时显示一下总耗时」;形制对齐 TraeWork 的
1530
+ * 「任务耗时 7m 14s」):<60s 给一位小数的秒(与旧收尾行那个「12.3s」同一形制);
1531
+ * ≥60s 给「7m 14s」;≥1h 给「1h 2m」。**拿不到耗时**(没给起点 / 负数 / 非数)回 '',
1532
+ * 调用方据此不拼这一段(宁可不写,也不编一个 0s)。 */
1533
+ function qaDurText(ms){
1534
+ /* null / undefined / 空串 = **没给起点**(不是「零秒」)→ 不编:`Number(null)` 是 0,
1535
+ 不先拦一道的话收尾行会冒出「总耗时 0.0s」这种假数(实测踩过)。 */
1536
+ if(ms == null || ms === "") return "";
1537
+ const n = Number(ms);
1538
+ if(!Number.isFinite(n) || n < 0) return "";
1539
+ const sec = Math.round(n / 1000);
1540
+ if(sec < 60) return (n / 1000).toFixed(1) + "s";
1541
+ const m = Math.floor(sec / 60);
1542
+ if(m < 60) return `${m}m ${sec % 60}s`;
1543
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
1544
+ }
1545
+ /** 收尾行 —— 对话里所有「这一轮结束了」的落笔处(普通对话 / 需求派发 / 模拟测试共用)。
1546
+ * `elapsedMs`(2026-10-10 加)= 这一轮的**总耗时**(墙上时钟:从发出去到收尾):
1547
+ * 给了就在状态语后面拼一段「· 总耗时 7m 14s」;不给(拿不到起点 / 老调用方)只写状态语。
1548
+ * ⚠️ 与引擎自报的 `latency_ms` 不是一回事:那个只算引擎自己那一跑,不含排队与冷启动,
1549
+ * 而且引擎不给就没有 —— 收尾行要的是「这一轮一共花了多久」,所以用墙上时钟自己算。 */
1550
+ function qaRenderDone(ok, label, host, elapsedMs){
1551
+ // host = 这一轮的落笔处(见 qaRunHost):后台跑的收尾行要落进它自己的画布,
1552
+ // 不能写死 #qa-stream —— 否则「切走那条跑完了」会在**当前显示的**会话里凭空多出一行收尾。
1553
+ const s = host || $("#qa-stream"); if(!s) return;
1554
+ const t = qaDurText(elapsedMs);
1555
+ const d = document.createElement("div");
1556
+ d.className = "qa-done" + (ok ? "" : " err") + " qa-pin";
1557
+ d.innerHTML = `<span class="d"></span><span></span>`;
1558
+ d.querySelector("span:last-child").textContent = String(label || "") + (t ? ` · 总耗时 ${t}` : "");
1559
+ s.appendChild(d);
1560
+ qaScrollIntoView();
1561
+ }