magic-agent 1.0.1 → 1.0.2

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 (40) hide show
  1. package/CLIENT-CONTRACT.md +298 -0
  2. package/README.md +35 -0
  3. package/client-ui/assets/OFL-Caveat.txt +93 -0
  4. package/client-ui/assets/caveat.woff2 +0 -0
  5. package/client-ui/index.html +246 -0
  6. package/client-ui/pc/app.css +5110 -0
  7. package/client-ui/pc/assets/damiao-mascot.png +0 -0
  8. package/client-ui/pc/assets/icon.png +0 -0
  9. package/client-ui/pc/index.html +154 -0
  10. package/client-ui/pc/js/00-base.js +52 -0
  11. package/client-ui/pc/js/03-chat-message.js +880 -0
  12. package/client-ui/pc/js/04-task-list.js +430 -0
  13. package/client-ui/pc/js/05-task-list-render.js +812 -0
  14. package/client-ui/pc/js/06-ask-answer.js +204 -0
  15. package/client-ui/pc/js/07-chat-stream-render.js +493 -0
  16. package/client-ui/pc/js/08-ask-event-map.js +171 -0
  17. package/client-ui/pc/js/09-stream-apply.js +367 -0
  18. package/client-ui/pc/js/10-markdown.js +430 -0
  19. package/client-ui/pc/js/18-askbox.js +262 -0
  20. package/client-ui/pc/js/19-attachments.js +679 -0
  21. package/client-ui/pc/js/29-msg-variants.js +462 -0
  22. package/client-ui/pc/js/90-glue.js +212 -0
  23. package/client-ui/pc/js/91-workbench-stubs.js +134 -0
  24. package/client-ui/pc/js/92-composer.js +406 -0
  25. package/client-ui/pc/js/93-rail.js +362 -0
  26. package/client-ui/pc/vendor/highlight.min.js +1262 -0
  27. package/client-ui/pc/vendor/marked.min.js +6 -0
  28. package/client-ui/pc/vendor/purify.min.js +3 -0
  29. package/client-ui/shared/conversation.css +256 -0
  30. package/client-ui/shared/conversation.js +1225 -0
  31. package/client-ui/shared/transport-http.js +123 -0
  32. package/client-ui-mobile/assets/OFL-Caveat.txt +93 -0
  33. package/client-ui-mobile/assets/caveat.woff2 +0 -0
  34. package/client-ui-mobile/index.html +173 -0
  35. package/npm/dist/darwin-arm64/magic-agent +0 -0
  36. package/npm/dist/darwin-x64/magic-agent +0 -0
  37. package/npm/dist/linux-arm64/magic-agent +0 -0
  38. package/npm/dist/linux-x64/magic-agent +0 -0
  39. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  40. package/package.json +4 -1
@@ -0,0 +1,462 @@
1
+ /* ===== 对话消息的「变体阶梯」(对齐 agents-anywhere 桌面端)=====
2
+ *
3
+ * 背景:anywhere 桌面端把时间线消息拆成一套**有层级的变体**,任何一条事件都能落到
4
+ * 某个合适形态上,且**绝不会因为形态不认识就把内容丢掉**:
5
+ *
6
+ * ① 气泡消息 用户 → 右对齐气泡;助手 → 无气泡通栏
7
+ * ② 单行标记行 Marker:16px 图标 + 等宽单行标题 + 状态徽章(+ 可折叠 JSON 明细)
8
+ * ③ 可折叠明细 有明细的标记行 → chevron + 展开一个代码面板
9
+ * ④ 分隔条 会话压缩这类「横切事件」→ 左右细线夹一行字
10
+ * ⑤ 工具卡 ToolCard:单行标题(人话)+ 展开 ToolDetailPanel(命令 / 文件变更 / 输出)
11
+ * ⑥ 代码面板帧 CodePanelFrame:label 头 + 复制按钮 + 代码体,工具明细与正文代码块共用
12
+ * ⑦ 文件变更行 FileChangeRow:动作徽章 + 路径 + Diff(4 列栅格 + 5 种行色)
13
+ *
14
+ * 本项目的现状是「只认识认识的那几种」:未知 type / 未配对结果 / 未配对 system 事件
15
+ * 一律落到 `.qa-note` 一行纯文本,**JSON 明细与状态全丢**;工具明细是裸 `<pre>`,
16
+ * 没有 label 头也没有复制;文件改动(Edit/Write)只能靠用户自己读参数 JSON。
17
+ *
18
+ * 本分片补的就是这套阶梯。三条纪律:
19
+ * 1. **形态不认识也要留住内容** —— 兜底永远是可展开的 JSON,而不是一行字;
20
+ * 2. **节点必须扛得住快照重建** —— 快照走 `innerHTML = snapshot`,所以状态一律落
21
+ * 在 class / data-* 上,点击一律走 #qa-stream 事件委托(见 07 的 bindQaStreamDelegation),
22
+ * 绝不在这里 addEventListener;
23
+ * 3. **只有一处写这份标记** —— 工具卡、标记行、代码面板都从这里出,业务侧不手搓。
24
+ *
25
+ * ⚠️ 字号沿用本项目(密集工作台):标签 9px、代码 10.5px、标题 10.5-11px。
26
+ * 不照抄 anywhere 的 14px —— 那个尺度是给消费级 App 的,搬进工作台会立刻显松。
27
+ */
28
+
29
+ /* ---------- 变体图标(24 视框 / currentColor / 描边,与全站内联 SVG 同一形制)---------- */
30
+ const QA_V_ICONS = {
31
+ // anywhere:command → SquareTerminal
32
+ terminal: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2.4"/><path d="m7 9.5 3 2.8-3 2.8M12.8 15.5h4"/></svg>',
33
+ // anywhere:file_change / artifact → FilePenLine
34
+ filepen: '<svg viewBox="0 0 24 24"><path d="M13 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h4"/><path d="M13 3l5 5v3M13 3v5h5"/><path d="M20.2 12.3a1.6 1.6 0 0 1 2.3 2.3l-5.2 5.2-3 .7.7-3z"/></svg>',
35
+ // anywhere:其余工具 → Hammer
36
+ hammer: '<svg viewBox="0 0 24 24"><path d="m15 12-8.4 8.4a2.1 2.1 0 1 1-3-3L12 9"/><path d="M17.6 15 22 10.6M20 5.5l-3 3"/><path d="m11.6 4.4 2.8-2.8 7.6 7.6-2.8 2.8a2 2 0 0 1-2.8 0l-4.8-4.8a2 2 0 0 1 0-2.8z"/></svg>',
37
+ // anywhere:agent_call → Bot
38
+ bot: '<svg viewBox="0 0 24 24"><path d="M12 8V4.6M9.4 4.6h5.2"/><rect x="4" y="8" width="16" height="12" rx="3"/><path d="M2.6 13.4v2.2M21.4 13.4v2.2M9.5 13v2M14.5 13v2"/></svg>',
39
+ // anywhere:web_search → 放大镜
40
+ search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.2"/><path d="m15.8 15.8 3.8 3.8"/></svg>',
41
+ // anywhere:mcp → 插头/节点
42
+ mcp: '<svg viewBox="0 0 24 24"><path d="M9 3v6M15 3v6"/><path d="M6 9h12v3a6 6 0 0 1-6 6 6 6 0 0 1-6-6z"/><path d="M12 18v3"/></svg>',
43
+ // anywhere:system / marker 兜底 → Clock(失败时换 CircleAlert)
44
+ clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7.2v4.8l3.2 1.9"/></svg>',
45
+ alert: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7.6v5M12 16.2h.01"/></svg>',
46
+ sparkles: '<svg viewBox="0 0 24 24"><path d="M10 4.2 11.3 8 15 9.3 11.3 10.6 10 14.4 8.7 10.6 5 9.3 8.7 8z"/><path d="M18 14.6v3.2M19.6 16.2h-3.2"/></svg>',
47
+ sep: '<svg viewBox="0 0 24 24"><path d="M4 12h16M7.5 8.6 12 4.1l4.5 4.5M16.5 15.4 12 19.9l-4.5-4.5"/></svg>',
48
+ chev: '<svg class="chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>',
49
+ copy: '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2.2"/><path d="M15 6.2V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h.2"/></svg>',
50
+ ok: '<svg viewBox="0 0 24 24"><path d="M4 12.5l5 5L20 6.5"/></svg>',
51
+ };
52
+ /* 代码面板/明细段通用的复制按钮。**要复制的原文的落位是固定的**:
53
+ 面板里放 `.qa-cf-bd pre`,工具明细段里放 `.qa-tool-sec pre` ——
54
+ 委托侧按这个约定取文本,所以按钮本身不必带任何 data-*(快照重建也照样能复制)。 */
55
+ const QA_COPY_BTN = '<button type="button" class="qa-cf-copy" aria-label="复制" title="复制">'
56
+ + '<span class="i-copy">' + QA_V_ICONS.copy + '</span><span class="i-ok">' + QA_V_ICONS.ok + '</span></button>';
57
+
58
+ /* ---------- 剪贴板 ----------
59
+ 本文件是全站唯一需要「复制节点文本」的地方(代码面板的复制按钮)。
60
+ 导航器 API 在 file:// 与无安全上下文下**不存在**(Electron 里也可能没授权),
61
+ 所以必须有回退:临时 textarea + execCommand。取不到就静默失败 —— 复制失败弹一堆错更烦。 */
62
+ function qaCopyText(text){
63
+ const s = String(text == null ? "" : text);
64
+ if(!s) return;
65
+ if(navigator.clipboard && navigator.clipboard.writeText){
66
+ navigator.clipboard.writeText(s).catch(()=>qaCopyFallback(s));
67
+ return;
68
+ }
69
+ qaCopyFallback(s);
70
+ }
71
+ function qaCopyFallback(s){
72
+ try{
73
+ const ta = document.createElement("textarea");
74
+ ta.value = s;
75
+ ta.setAttribute("readonly", "");
76
+ ta.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0";
77
+ document.body.appendChild(ta);
78
+ ta.select();
79
+ document.execCommand("copy");
80
+ ta.remove();
81
+ }catch(e){ /* 复制不可用:不打扰用户 */ }
82
+ }
83
+
84
+ /* ---------- ②⑦ 状态徽章(anywhere TimelineStatusBadge)----------
85
+ `h-5 text-[11px]`,failed → destructive,其余 secondary。这里按本项目的字号收成 15px / 9px,
86
+ 色阶走令牌(玉=成、朱砂=败、琥珀=跑、灰=中性),与 .qa-agent-chip 同一套语言。 */
87
+ function qaStatusTone(status){
88
+ const s = String(status || "").toLowerCase();
89
+ if(s === "failed" || s === "error" || s === "cancelled" || s === "interrupted" || s === "canceled") return "err";
90
+ if(s === "running" || s === "pending" || s === "waiting_approval" || s === "waiting" || s === "in_progress") return "run";
91
+ if(s === "ok" || s === "completed" || s === "complete" || s === "done" || s === "success") return "ok";
92
+ return "";
93
+ }
94
+ function qaStatusLabel(status){
95
+ const s = String(status || "").toLowerCase();
96
+ return ({
97
+ pending: "待处理", running: "进行中", waiting_approval: "待确认", in_progress: "进行中",
98
+ failed: "失败", error: "失败", cancelled: "已取消", canceled: "已取消", interrupted: "已中断",
99
+ completed: "已完成", complete: "已完成", done: "已完成", success: "已完成", ok: "已完成",
100
+ })[s] || String(status || "");
101
+ }
102
+ function qaStatusBadgeHtml(status){
103
+ const s = String(status || "").trim();
104
+ if(!s) return "";
105
+ const tone = qaStatusTone(s);
106
+ return `<span class="qa-st${tone ? " " + tone : ""}">${escHtml(qaStatusLabel(s))}</span>`;
107
+ }
108
+
109
+ /* ---------- ⑥ 代码面板帧(anywhere CodePanelFrame)----------
110
+ anywhere 的形制:外框 `rounded-xl border`;头 36px + 底边 + 浅灰底,label 在左、
111
+ 复制按钮在右(点后 1.2s 变勾);体是横向可滚的等宽代码。
112
+ ⚠️ 工具明细、JSON 兜底、正文代码块**共用这一个帧** —— 两处各写一套,观感必然分叉
113
+ (本项目原来工具卡是裸 `<pre>` + 「输入/输出」小标题,正文代码块又是另一套样式)。
114
+ ⚠️ 复制按钮不带监听:点击走 #qa-stream 事件委托(快照重建后照样能点)。
115
+ 要复制的原文**永远放在 `.qa-cf-bd pre` 里**(diff 形态放一份 hidden 的),
116
+ 委托侧只认这一个位置,不必给每个按钮挂 data-*。 */
117
+ function qaCodeFrameNode(label, code, opts){
118
+ const o = opts || {};
119
+ const wrap = document.createElement("div");
120
+ wrap.className = "qa-cf" + (o.flush ? " qa-cf-flush" : "");
121
+ wrap.innerHTML =
122
+ '<div class="qa-cf-hd">'
123
+ + '<span class="qa-cf-lb"></span>'
124
+ + '<span class="qa-cf-hd-r">'
125
+ + (o.action ? '<span class="qa-cf-act"></span>' : '')
126
+ + '<button type="button" class="qa-cf-copy" aria-label="复制" title="复制">'
127
+ + '<span class="i-copy">' + QA_V_ICONS.copy + '</span><span class="i-ok">' + QA_V_ICONS.ok + '</span>'
128
+ + '</button></span></div>'
129
+ + '<div class="qa-cf-bd"></div>';
130
+ wrap.querySelector(".qa-cf-lb").textContent = String(label || "code");
131
+ if(o.action) wrap.querySelector(".qa-cf-act").textContent = String(o.action);
132
+ const bd = wrap.querySelector(".qa-cf-bd");
133
+ if(o.rows && o.rows.length){
134
+ const src = document.createElement("pre");
135
+ src.hidden = true;
136
+ src.textContent = o.rawText != null ? String(o.rawText) : o.rows.map(r=>r.text).join("\n");
137
+ bd.appendChild(src);
138
+ const d = document.createElement("div");
139
+ d.className = "qa-diff" + (o.showNo ? "" : " qa-diff-nono");
140
+ d.innerHTML = qaDiffRowsHtml(o.rows, !!o.showNo);
141
+ bd.appendChild(d);
142
+ }else{
143
+ const pre = document.createElement("pre");
144
+ pre.textContent = String(code == null ? "" : code);
145
+ bd.appendChild(pre);
146
+ }
147
+ return wrap;
148
+ }
149
+
150
+ /* ---------- ⑦ Diff 行(anywhere DiffPanel / buildDiffRows)----------
151
+ 四列栅格「符号 / 行号 / 竖分隔 / 正文」,五种行色:add 玉绿、del 朱砂、hunk 靛、file 灰、ctx 正文。
152
+ `showNo=false` 时不画行号 —— 那是**推导出来的 diff**(见 qaDerivedDiffRows),
153
+ 行号无从得知,画上去就是编数据。 */
154
+ function qaDiffRowsHtml(rows, showNo){
155
+ return (rows || []).map(r=>{
156
+ const k = r.kind === "add" ? "add" : r.kind === "del" ? "del" : r.kind === "hunk" ? "hunk" : r.kind === "file" ? "file" : "ctx";
157
+ const sign = k === "add" ? "+" : k === "del" ? "-" : "";
158
+ const no = showNo && r.no != null ? String(r.no) : "";
159
+ return '<div class="qa-diff-row ' + k + '">'
160
+ + '<span class="s">' + sign + '</span>'
161
+ + '<span class="n">' + escHtml(no) + '</span>'
162
+ + '<span class="b"></span>'
163
+ + '<span class="t">' + escHtml(r.text) + '</span>'
164
+ + '</div>';
165
+ }).join("");
166
+ }
167
+ /** 引擎给了**真的** unified diff 才走这里(tool_result 里带 @@ 与 ---/+++ 时)。 */
168
+ function qaLooksLikeDiff(text){
169
+ const t = String(text == null ? "" : text);
170
+ return /(^|\n)@@ /.test(t) && (/(^|\n)\+\+\+ /.test(t) || /(^|\n)--- /.test(t));
171
+ }
172
+ function qaDiffTextToRows(text){
173
+ const rows = [];
174
+ let no = null;
175
+ String(text == null ? "" : text).split("\n").forEach(line=>{
176
+ if(/^@@ /.test(line)){
177
+ const m = /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)/.exec(line);
178
+ no = m ? Number(m[1]) : null;
179
+ rows.push({ kind:"hunk", text:line });
180
+ return;
181
+ }
182
+ if(/^(diff |index |--- |\+\+\+ )/.test(line)){ rows.push({ kind:"file", text:line }); return; }
183
+ if(line.charAt(0) === "+"){ rows.push({ kind:"add", no:no, text:line.slice(1) }); if(no != null) no++; return; }
184
+ if(line.charAt(0) === "-"){ rows.push({ kind:"del", no:no, text:line.slice(1) }); return; }
185
+ rows.push({ kind:"ctx", no:no, text:line.charAt(0) === " " ? line.slice(1) : line });
186
+ if(no != null && line !== "") no++;
187
+ });
188
+ return rows;
189
+ }
190
+ /** 由工具参数的 old/new 文本**推导** diff(Edit / MultiEdit / Write 走这条)。
191
+ * 与真 diff 的区别:没有上下文行、也不知道行号 —— 所以只画「- 旧 / + 新」两组,
192
+ * 不做 hunk、不画行号。宁可少画,也不编行号。 */
193
+ function qaDerivedDiffRows(oldText, newText){
194
+ const rows = [];
195
+ const push = (kind, txt)=>{
196
+ const s = String(txt == null ? "" : txt);
197
+ if(!s.length) return;
198
+ s.split("\n").forEach(t=>rows.push({ kind:kind, text:t }));
199
+ };
200
+ push("del", oldText);
201
+ push("add", newText);
202
+ return rows;
203
+ }
204
+
205
+ /* ---------- ②③ 单行标记行 + JSON 兜底(anywhere Marker / JsonMarker / ArtifactCard)----------
206
+ anywhere 里所有「不是正文、也不是完整工具卡」的条目都落在这一个形态上:
207
+ system / marker / artifact / diagnostic / unknown →
208
+ 16px 图标 + 等宽单行标题 + 状态徽章(+ 展开一份 JSON)。
209
+ 这是整套阶梯里最重要的一格:**形态不认识 ≠ 内容丢掉**。
210
+ 本项目原来只有 `.qa-note`(一行 10px 灰字,见 03-chat-message 的 note),
211
+ JSON 与状态全丢 —— 引擎抛个没见过的生命周期事件,画面上只剩「系统 · xxx」,
212
+ 排障要的字段其实都在事件里,只是没人画。
213
+
214
+ ⚠️ 明细一律走 qaCodeFrameNode 的 JSON 面板:与工具明细、正文代码块同一个帧,
215
+ 观感不会分叉;且 JSON 是**可复制的**(原来那行灰字只能靠肉眼抄)。 */
216
+ function qaHasDetail(v){
217
+ if(v == null) return false;
218
+ if(typeof v === "string") return v.trim().length > 0;
219
+ if(Array.isArray(v)) return v.length > 0;
220
+ if(typeof v === "object") return Object.keys(v).length > 0;
221
+ return true;
222
+ }
223
+ function qaMarkerNode(opts){
224
+ const o = opts || {};
225
+ const det = o.detail;
226
+ const hasDet = qaHasDetail(det);
227
+ const wrap = document.createElement("div");
228
+ wrap.className = "qa-mk" + (o.destructive ? " err" : "");
229
+ const row = document.createElement("div");
230
+ row.className = "qa-mk-row";
231
+ row.dataset.tog = hasDet ? "1" : "0";
232
+ row.title = String(o.hint || "");
233
+ row.innerHTML = (hasDet ? '<span class="qa-mk-chev">' + QA_V_ICONS.chev + '</span>'
234
+ : '<span class="qa-mk-gap"></span>')
235
+ + '<span class="qa-mk-ico">' + (o.icon || (o.destructive ? QA_V_ICONS.alert : QA_V_ICONS.clock)) + '</span>'
236
+ + '<span class="qa-mk-tt"></span>'
237
+ + (o.status ? qaStatusBadgeHtml(o.status) : '');
238
+ row.querySelector(".qa-mk-tt").textContent = String(o.title || "");
239
+ wrap.appendChild(row);
240
+ if(hasDet){
241
+ const bd = document.createElement("div");
242
+ bd.className = "qa-mk-bd";
243
+ const isStr = typeof det === "string";
244
+ bd.appendChild(qaCodeFrameNode(
245
+ String(o.detailLabel || (isStr ? "detail" : "json")),
246
+ isStr ? String(det) : JSON.stringify(det, null, 2)));
247
+ wrap.appendChild(bd);
248
+ }
249
+ return wrap;
250
+ }
251
+
252
+ /* ---------- ④ 分隔条(anywhere Marker variant="separator")----------
253
+ 用在「横切整条会话」的事件上 —— anywhere 那边是**会话压缩**(`MarkerContentKind="compact"`,
254
+ 进行中/已完成两种文案)。分隔条不是谁说的话,形状本身就在说「这里是一条分界」。
255
+ ⚠️ 它**不承载明细**(没有可展开体),所以原始事件信息只能挂在 title 上(悬停可见)——
256
+ 这也是为什么「拿不准的 system 事件」不该走它,而该走标记行(有 JSON 兜底)。 */
257
+ function qaSepNode(text, opts){
258
+ const o = opts || {};
259
+ const d = document.createElement("div");
260
+ d.className = "qa-sep";
261
+ d.innerHTML = '<span class="qa-sep-t"></span>';
262
+ d.querySelector(".qa-sep-t").textContent = String(text || "");
263
+ if(o.title) d.title = String(o.title);
264
+ return d;
265
+ }
266
+
267
+ /* ---------- ⑤ 工具归类与人话标题(anywhere timelineToolKind / timelineToolTitle)----------
268
+ anywhere 不给用户看内部工具名与参数 JSON,而是按类别写一句人话:
269
+ command →「运行了 <命令>」;file_change →「修改了 <文件>」;web_search →「搜索了 <词>」;
270
+ agent_call →「<动作>:<描述>」;mcp →「<server> / <tool>」。
271
+ 本项目原来行里是「工具名 + 参数串前 60 字」—— 那是给排障的人看的,不是给用的人看的。
272
+ ⚠️ 工具名与参数原文**不丢**:留在行 title(悬停可见)与明细段里。 */
273
+ const QA_TOOL_RULES = [
274
+ { kind: "file_change", re: /^(edit|multiedit|multi_edit|write|notebookedit|notebook_edit|str_replace_editor|str_replace_based_edit_tool|apply_patch|create_file|delete_file)$/i },
275
+ { kind: "agent_call", re: /^(agent|task|subagent)$/i },
276
+ { kind: "web_search", re: /^(websearch|web_search|search_query)$/i },
277
+ { kind: "web_fetch", re: /^(webfetch|web_fetch|fetch)$/i },
278
+ { kind: "read", re: /^(read|notebookread|notebook_read|view|cat)$/i },
279
+ { kind: "search", re: /^(grep|glob|search|find|ls|list_dir|listdir)$/i },
280
+ { kind: "command", re: /^(bash|shell|zsh|sh|exec|execute|run|terminal|command|cmd|powershell)$/i },
281
+ ];
282
+ function qaToolKindOf(name, argsObj){
283
+ const n = String(name || "").trim();
284
+ if(/^mcp__/i.test(n) || /^mcp[_.]/.test(n)) return "mcp";
285
+ for(const r of QA_TOOL_RULES) if(r.re.test(n)) return r.kind;
286
+ const o = argsObj && typeof argsObj === "object" ? argsObj : null;
287
+ if(o){
288
+ if(o.command != null || o.cmd != null) return "command";
289
+ if(o.file_path != null || o.notebook_path != null) return "file_change";
290
+ if(o.old_string != null || o.new_string != null || Array.isArray(o.edits)) return "file_change";
291
+ if(o.query != null) return "web_search";
292
+ if(o.url != null) return "web_fetch";
293
+ if(o.pattern != null) return "search";
294
+ }
295
+ return "tool";
296
+ }
297
+
298
+ /* 形态合并(2026-09-29):magic-agent 的归一化层会随 tool_use 事件给出 `tool_kind`
299
+ *(command / file_change / web_search / mcp / agent_call / input_request / permission /
300
+ * tool_call / unknown,词表对齐 agents-anywhere 的 ToolContentKind)。
301
+ *
302
+ * 但**不能**直接让服务端那份覆盖本地判定:本地这套按名字与参数分得更细
303
+ *(read / search / web_fetch 是本地才有的更细形态),盲目覆盖会让本来认识的工具
304
+ * 掉回通用卡(实测 Read 会从「文件」图标变回通用锤子)。
305
+ *
306
+ * 规则:**本地认出来就听本地的;本地只认得通用 `tool` 时,采用服务端给的形态**。
307
+ * 这样既不丢精度,又能覆盖本地名字表没见过的引擎(那是这份字段真正的价值所在)。
308
+ * ⚠️ 与 magic-test 仓库的同名函数逐字一致:两个界面共用一份词表,别各改各的。 */
309
+ function qaToolKindMerge(name, argsObj, serverKind){
310
+ const local = qaToolKindOf(name, argsObj);
311
+ if(local !== "tool") return local;
312
+ const m = { command:"command", file_change:"file_change", web_search:"web_search",
313
+ mcp:"mcp", agent_call:"agent_call",
314
+ /* 服务端更粗的那几档在本地没有对应形态 → 落到通用卡(不硬造新形态) */
315
+ tool_call:"tool", input_request:"tool", permission:"tool", unknown:"tool" };
316
+ return m[String(serverKind || "")] || "tool";
317
+ }
318
+ function qaToolIconFor(kind){
319
+ if(kind === "command") return QA_V_ICONS.terminal;
320
+ if(kind === "file_change") return QA_V_ICONS.filepen;
321
+ if(kind === "agent_call") return QA_V_ICONS.bot;
322
+ if(kind === "web_search") return QA_V_ICONS.search;
323
+ if(kind === "web_fetch") return QA_V_ICONS.search;
324
+ if(kind === "mcp") return QA_V_ICONS.mcp;
325
+ if(kind === "read" || kind === "search") return QA_V_ICONS.filepen;
326
+ return QA_V_ICONS.hammer;
327
+ }
328
+ /** 参数里的「目标」:路径 / 查询词 / URL / 模式 —— 人话标题的宾语 */
329
+ function qaToolTargetOf(argsObj){
330
+ const o = argsObj && typeof argsObj === "object" ? argsObj : null;
331
+ if(!o) return "";
332
+ return String(o.file_path || o.notebook_path || o.path || o.query || o.url
333
+ || o.pattern || o.command || o.cmd || "").trim();
334
+ }
335
+ function qaClip(s, n){
336
+ const t = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
337
+ const max = n || 72;
338
+ return t.length > max ? t.slice(0, max) + "…" : t;
339
+ }
340
+ function qaBaseName(p){
341
+ const t = String(p || "").replace(/\\/g, "/").replace(/\/+$/, "");
342
+ const i = t.lastIndexOf("/");
343
+ return i >= 0 ? t.slice(i + 1) || t : t;
344
+ }
345
+ /** 工具行标题(人话)+ 供 title 属性用的原文(排障时悬停可见) */
346
+ function qaToolHumanTitle(name, argsObj, argsText){
347
+ const kind = qaToolKindOf(name, argsObj);
348
+ const o = argsObj && typeof argsObj === "object" ? argsObj : null;
349
+ const path = o ? String(o.file_path || o.notebook_path || o.path || "").trim() : "";
350
+ if(kind === "file_change"){
351
+ const many = o && Array.isArray(o.edits) ? o.edits.length : 0;
352
+ if(path) return (qaFileChangeAction(name, o) === "add" ? "写入 " : "编辑 ") + qaClip(path, 64);
353
+ return many > 1 ? "修改了 " + many + " 处" : "修改文件";
354
+ }
355
+ if(kind === "command") return "运行了 " + (qaClip(o && (o.command || o.cmd), 80) || qaClip(argsText, 80) || "命令");
356
+ if(kind === "web_search") return "搜索了 " + (qaClip(o && (o.query || o.q), 64) || "关键词");
357
+ if(kind === "web_fetch") return "抓取 " + (qaClip(o && (o.url || o.uri), 64) || "网页");
358
+ if(kind === "read") return "读取 " + (qaClip(path, 64) || "文件");
359
+ if(kind === "search") return "查找 " + (qaClip(o && (o.pattern || o.query), 48) || "匹配项")
360
+ + (path ? " · " + qaClip(qaBaseName(path) || path, 32) : "");
361
+ if(kind === "agent_call"){
362
+ const d = qaClip(o && (o.description || o.subagent_type), 64);
363
+ return d ? "委派:" + d : "委派子代理";
364
+ }
365
+ if(kind === "mcp"){
366
+ const parts = String(name || "").split(/__|_/).filter(Boolean);
367
+ return parts.length >= 2 ? parts[0] + " / " + parts.slice(1).join(" ") : String(name || "mcp");
368
+ }
369
+ const tgt = qaClip(qaToolTargetOf(o), 56);
370
+ return tgt ? String(name || "tool") + " " + tgt : String(name || "tool");
371
+ }
372
+
373
+ /* ---------- ⑦ 文件变更的推导与渲染(anywhere FileChangeRow)----------
374
+ magic-agent 的事件里没有 file_change 这一类,但**改动本身就在工具参数里**:
375
+ Edit 带 old_string/new_string、MultiEdit 带 edits[]、Write 带 content。
376
+ 所以这一格是「从参数推导」而不是「等引擎给」—— 用户不必再读参数 JSON 才知道改了哪一行。
377
+ ⚠️ 推导出的 diff 没有上下文行与行号(见 qaDerivedDiffRows),不画 hunk、不编行号。 */
378
+ function qaFileChangeAction(name, argsObj){
379
+ const n = String(name || "").toLowerCase();
380
+ const o = argsObj && typeof argsObj === "object" ? argsObj : {};
381
+ if(/^(write|create_file)$/.test(n)) return "add";
382
+ if(/delete_file/.test(n)) return "delete";
383
+ if(o.old_string == null && o.new_string == null && !Array.isArray(o.edits) && o.content != null) return "add";
384
+ return "modify";
385
+ }
386
+ function qaFileChangeOf(name, argsObj){
387
+ const o = argsObj && typeof argsObj === "object" ? argsObj : null;
388
+ if(!o) return null;
389
+ const path = String(o.file_path || o.notebook_path || o.path || "").trim();
390
+ if(!path) return null;
391
+ const action = qaFileChangeAction(name, o);
392
+ let rows = [];
393
+ if(Array.isArray(o.edits) && o.edits.length){
394
+ o.edits.forEach(e=>{ if(e) rows = rows.concat(qaDerivedDiffRows(e.old_string, e.new_string)); });
395
+ }else if(o.old_string != null || o.new_string != null){
396
+ rows = qaDerivedDiffRows(o.old_string, o.new_string);
397
+ }else{
398
+ rows = qaDerivedDiffRows("", o.content != null ? o.content : o.new_source);
399
+ }
400
+ if(!rows.length && !path) return null;
401
+ return { action: action, path: path, rows: rows };
402
+ }
403
+ /** 文件变更行:动作徽章 + 路径 + Diff 面板(内部复用代码面板帧,与工具明细同一帧) */
404
+ function qaFileChangeNode(change){
405
+ const wrap = document.createElement("div");
406
+ wrap.className = "qa-fc";
407
+ const hd = document.createElement("div");
408
+ hd.className = "qa-fc-hd";
409
+ hd.innerHTML = '<span class="qa-fc-ico">' + QA_V_ICONS.filepen + '</span>'
410
+ + '<span class="qa-fc-tag"></span><span class="qa-fc-p"></span>';
411
+ const tag = hd.querySelector(".qa-fc-tag");
412
+ const act = change && change.action;
413
+ tag.className = "qa-fc-tag " + (act === "add" ? "add" : act === "delete" ? "del" : "mod");
414
+ tag.textContent = act === "add" ? "新建" : act === "delete" ? "删除" : "修改";
415
+ hd.querySelector(".qa-fc-p").textContent = String((change && change.path) || "");
416
+ hd.title = String((change && change.path) || "");
417
+ wrap.appendChild(hd);
418
+ if(change && change.rows && change.rows.length){
419
+ wrap.appendChild(qaCodeFrameNode("diff", "", { rows: change.rows, showNo: !!change.showNo, flush: true }));
420
+ }
421
+ return wrap;
422
+ }
423
+
424
+ /* ---------- 长消息折叠(anywhere CollapsibleUserMessage)----------
425
+ *
426
+ * 这套机制本项目**早就有了**,但它只长在一条路径上:审核引擎的报告(12-req-board 的
427
+ * qaReqEngineMsg 里内联写死)。而**用户自己的气泡没有** —— 偏偏用户在这个工作台里最常干的事
428
+ * 就是把一整份需求正文粘进来,一条消息能把画布顶掉两屏。
429
+ * anywhere 的 `CollapsibleUserMessage` 正是给**用户消息**用的:超过 10 行就截断 + 底部渐隐 +
430
+ * 一枚展开按钮(内容一个字不删,只改可见高度)。
431
+ *
432
+ * 这里把机制收敛成**一处**:判据、按钮、类名都由 qaFoldMaybe 给,谁要折叠就调它一次。
433
+ * ⚠️ 类名沿用既有的 `.qa-fold-btn` / `data-qa-fold`(引擎那份的 `.qa-engine-fold` 作为别名保留):
434
+ * 13-dispatch-stream 的**事件委托**与 tools/verify-review-ui.mjs 都按这套名字断言,
435
+ * 改名会同时打断这两处。新调用方用 `.qa-fold`,CSS 里与 `.qa-engine-fold` 并列成一组。
436
+ * ⚠️ 折叠态靠**类**表达(不靠 inline style):快照恢复走 `innerHTML = snapshot`,
437
+ * 类落在 HTML 上会跟着还原,高度不会「恢复后又弹开」。
438
+ * ⚠️ 判据与审核报告同一口径(>500 字符 或 >12 行)—— 阈值只写这一处。
439
+ * anywhere 用的是 10 行;这里沿用本项目已有的数字,免得同一屏里两套折叠阈值。 */
440
+ const QA_FOLD_MAX_CHARS = 500;
441
+ const QA_FOLD_MAX_LINES = 12;
442
+ /** 给 host 按需挂折叠(返回是否挂了)。cls 默认 "qa-fold";审核报告传 "qa-engine-fold"。 */
443
+ function qaFoldMaybe(host, text, cls){
444
+ if(!host) return false;
445
+ /* 带附件的消息**不折**(2026-09-28):附件缩略图就在同一个 host 里
446
+ (qaShotsMount 把 `.qa-atts` append 进 host),折起来会把「我特意要你看的图」
447
+ 一起折掉 —— 而折叠的目的是让**一长段文字**不把对话顶满。
448
+ 判据取**渲染后**的 DOM(`.qa-atts` 真在才跳过),不看入参。 */
449
+ if(host.querySelector(".qa-atts")) return false;
450
+ const body = String(text == null ? "" : text);
451
+ const lines = body.split("\n").filter((x)=>x.trim()).length;
452
+ if(!(body.length > QA_FOLD_MAX_CHARS || lines > QA_FOLD_MAX_LINES)) return false;
453
+ host.classList.add(cls || "qa-fold");
454
+ const btn = document.createElement("button");
455
+ btn.type = "button";
456
+ btn.className = "qa-fold-btn";
457
+ btn.setAttribute("data-qa-fold", "1"); // 事件委托认它(对话画布会被整份重建)
458
+ btn.setAttribute("data-qa-lines", String(lines));
459
+ btn.textContent = "展开全文(共 " + lines + " 行)";
460
+ if(host.parentNode) host.parentNode.insertBefore(btn, host.nextSibling);
461
+ return true;
462
+ }