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.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +69 -1
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +260 -33
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +101 -10
- package/npm/bin/magic-client.js +118 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -20,7 +20,30 @@ function qaAskFromEvent(ask){
|
|
|
20
20
|
return out.length ? out : null;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/* ---------- 工具结果在**画布上**的显示上限(2026-10-20)----------
|
|
24
|
+
用户报「读取历史太慢」。实测一条会话的 404 条 tool_result 共 28.1MB,
|
|
25
|
+
其中 30 条 >12KB 就占了 27.7MB(最大单条 1.6MB)。这些全量字串既进 `<pre>`,
|
|
26
|
+
又要过 `qaLooksLikeDiff` / `qaArtCollect` 两道全量正则,回放时把主线程卡住二十多秒。
|
|
27
|
+
工具卡明细是「看几眼」用的,不是日志查看器 —— 超限就只画前 N 字符,
|
|
28
|
+
并在末尾**如实标注**总长度(不假装这就是全部)。
|
|
29
|
+
⚠️ 只截**显示**:ctx.onStep 仍拿全量,台账落库口径不变。 */
|
|
30
|
+
const QA_TOOL_OUT_MAX = 12000;
|
|
31
|
+
function qaToolOutForDisplay(text){
|
|
32
|
+
const s = String(text == null ? "" : text);
|
|
33
|
+
if(s.length <= QA_TOOL_OUT_MAX) return s;
|
|
34
|
+
const kb = (s.length / 1024).toFixed(1);
|
|
35
|
+
return s.slice(0, QA_TOOL_OUT_MAX)
|
|
36
|
+
+ "\n\n… 已截断(共 " + kb + " KB,这里只显示前 " + (QA_TOOL_OUT_MAX / 1024) + " KB)";
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
function qaStreamApply(ev, ctx){
|
|
40
|
+
/* 会话锚点:**任何**带 session_id 的事件到场就绑(2026-10-16 用户:「pc端也不要等完全结束在
|
|
41
|
+
获取 sessionid」)。主进程这一轮走 `--events` 形态(见 desktop/main.js 的 buildAgentArgs
|
|
42
|
+
调用)→ core 一拿到 id 就每行都带,所以这里通常在第一行(引擎 init)就绑上了,不再等
|
|
43
|
+
result / turn_end。下面各分支里同款的绑定**保留**:老 CLI(非 --events 形态)只在收尾那几条
|
|
44
|
+
上带 id,仍靠它们落库;qaSessionBindFor 幂等,重复绑定无副作用。两套拼写都认。 */
|
|
45
|
+
const sidAny = ev && (ev.session_id || ev.sessionId);
|
|
46
|
+
if(sidAny && ctx && typeof ctx.onSession === "function") ctx.onSession(sidAny);
|
|
24
47
|
for(const e of qaEventNormalize(ev)) qaStreamApplyOne(e, ctx);
|
|
25
48
|
}
|
|
26
49
|
|
|
@@ -38,6 +61,16 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
38
61
|
const evParent = ev.parent != null ? String(ev.parent)
|
|
39
62
|
: (ev.parent_tool_use_id != null ? String(ev.parent_tool_use_id) : null);
|
|
40
63
|
const agOf = (id)=> id != null ? agents.get(id) : null;
|
|
64
|
+
/* 会话锚点:**任何**带 session_id 的事件都当场认(2026-10-15)。
|
|
65
|
+
⚠️ 以前只在 ask / result / error / turn_failed / end / turn_end 这六条分支里认 ——
|
|
66
|
+
等于「等到终点事件才绑锚点」。而 core 在 `--events` 下一拿到 id 就**每行都带**
|
|
67
|
+
(internal/cli/events.go 的 eventAttrs),于是这段时间窗里的信息全被浪费:中途被打断、
|
|
68
|
+
或终帧本来就不带 id 的引擎(dsh 的续接轮 / SDK 通道不可用的回退轮),客户端就永远学不到
|
|
69
|
+
锚点,下一步追问被发成了**新会话**(真实报障:追问变成了新任务)。
|
|
70
|
+
抬到 switch 之前 → 谁先带 id 谁先绑;两套拼写都认(session_id / sessionId)。
|
|
71
|
+
历史回放不受影响:那条路径的 ctx.onSession 是空函数(宿主侧只读回放)。 */
|
|
72
|
+
const sidAny = ev.session_id || ev.sessionId;
|
|
73
|
+
if(sidAny && typeof ctx.onSession === "function") ctx.onSession(sidAny);
|
|
41
74
|
const finishTools = (ok)=>{ // 流结束/失败时兜底:还在转圈的工具卡一律收账
|
|
42
75
|
ctx.tools.forEach(tc=>{ if(tc.state === "running") tc.done("", ok); });
|
|
43
76
|
ctx.tools.clear();
|
|
@@ -66,10 +99,14 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
66
99
|
}
|
|
67
100
|
/* 头像右边的「引擎 · 模型」小标同一时机跟成实际值(与胶囊同一条口径:
|
|
68
101
|
界面说 A、实际跑 B 不行)。机器侧 `.qa-trace`(审查轮 / 数字分身的活动行)
|
|
69
|
-
是 qaTraceToLeft 另建的节点,句柄管不到它 —— 走 `__traceWrap` 直接改文字。
|
|
70
|
-
|
|
102
|
+
是 qaTraceToLeft 另建的节点,句柄管不到它 —— 走 `__traceWrap` 直接改文字。
|
|
103
|
+
⚠️ `whoModelOnly`(2026-10-20):宿主把「运行中」并到头像那一行之后,那一行
|
|
104
|
+
只放得下**模型名**(见 qaWhoLabel 的 modelOnly)—— 由宿主经 ctx 传下来。
|
|
105
|
+
不接这一档的话,降级换模型后这里会把长串写回去、秒数又被挤到换行。 */
|
|
106
|
+
const whoModelOnly = !!(ctx && ctx.whoModelOnly);
|
|
107
|
+
if(typeof h.setWho === "function") h.setWho(eng, mdl, whoModelOnly);
|
|
71
108
|
const tw = (h.el && h.el.__traceWrap) ? h.el.__traceWrap.querySelector(".qa-who") : null;
|
|
72
|
-
if(tw) tw.textContent = qaWhoLabel(eng, mdl);
|
|
109
|
+
if(tw) tw.textContent = qaWhoLabel(eng, mdl, whoModelOnly);
|
|
73
110
|
if(mdl && retry) qaModelSwitched.add(ev.rid); // 记一笔:这轮换过模型(end 时重取链,见 Set 注释)
|
|
74
111
|
if(mdl && ctx && ctx.taskId && typeof qaTaskSetModel === "function") qaTaskSetModel(ctx.taskId, mdl);
|
|
75
112
|
if(mdl && retry && qaModelPick && qaModelPick !== mdl && typeof qaModelSet === "function"){
|
|
@@ -212,7 +249,7 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
212
249
|
// 期望载荷: { tid|id, text(结果串,可缺省), ok(布尔,缺省 true) }
|
|
213
250
|
const tid = ev.tid != null ? ev.tid : ev.id;
|
|
214
251
|
const tc = tid != null ? ctx.tools.get(tid) : null;
|
|
215
|
-
if(tc) tc.done(ev.text || "", ev.ok !== false);
|
|
252
|
+
if(tc) tc.done(qaToolOutForDisplay(ev.text || ""), ev.ok !== false);
|
|
216
253
|
else if(evParent && agOf(evParent) && typeof agOf(evParent).subNote === "function")
|
|
217
254
|
agOf(evParent).subNote("结果回执:" + String(ev.text || "").slice(0, 400)); // ③ 无卡可填 → 父卡注脚
|
|
218
255
|
else if(typeof h.marker === "function"){
|
|
@@ -227,7 +264,7 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
227
264
|
title: "未配对结果" + (tid != null && tid !== "" ? " · " + String(tid).slice(0, 14) : ""),
|
|
228
265
|
status: fail ? "failed" : "completed",
|
|
229
266
|
destructive: fail,
|
|
230
|
-
detail: ev.text ?
|
|
267
|
+
detail: ev.text ? qaToolOutForDisplay(ev.text) : null,
|
|
231
268
|
detailLabel: "output",
|
|
232
269
|
hint: "这条结果配不到对应的工具卡(工具事件缺 id,或结果晚于收尾到达)",
|
|
233
270
|
});
|
|
@@ -294,6 +331,12 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
294
331
|
case "result":
|
|
295
332
|
// result 行是权威全文:以它覆盖正文(流式增量可能丢字符),并按 markdown 完整解析
|
|
296
333
|
if(ev.text) h.setResult(ev.text);
|
|
334
|
+
/* 权威正文同时**改成台账的那一份**(2026-10-10)——画面(h.setResult)与台账
|
|
335
|
+
(run.msgText)必须同源:台账此前只由流式 text 事件逐段累加,而主进程可能在这条
|
|
336
|
+
result 里给的是**清理过**的正文(如剥掉拼在末尾的限流/额度通告,见 main.js 的
|
|
337
|
+
turn_end 分支)。不跟着改的话,画面说的是失败、历史重画时那句通告却又回来了。
|
|
338
|
+
⚠️ 守卫与 h.setResult 一致(ev.text 非空才认),别把空正文当成「正文就是空」。 */
|
|
339
|
+
if(ev.text && ctx && typeof ctx.onResult === "function") ctx.onResult(ev.text);
|
|
297
340
|
if(ev.latency_ms != null) ctx.setMs(ev.latency_ms);
|
|
298
341
|
// 会话 id 写回任务(追问续接同一会话);sessionId / session_id 双拼写兼容
|
|
299
342
|
if(ev.sessionId || ev.session_id) ctx.onSession(ev.sessionId || ev.session_id);
|
|
@@ -308,6 +351,25 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
308
351
|
qaRunEnd(h);
|
|
309
352
|
ctx.onError(ev);
|
|
310
353
|
break;
|
|
354
|
+
/* ⚠️ 上游的**异常终态**(magic-agent 的 KindTurnFailed,2026-10-10):
|
|
355
|
+
「引擎没走完协议(没吐 result 行)」与「引擎把限流/额度通告当正文发回来」两种情形
|
|
356
|
+
都由它收尾 —— 判决书(error / reason)与**剥掉通告的正文**(text)都在事件上,
|
|
357
|
+
是 magic-agent 那边**统一封装**过的(用户 2026-10-10 定调:「应该是给 magic-agent 的
|
|
358
|
+
返回统一这种异常的封装 然后才是 ui 线上」)。界面因此**不需要认识通告长什么样**:
|
|
359
|
+
这里只管按终态收尾,与 error 分支逐条一致。
|
|
360
|
+
与 error 的差别只有一处:它**带正文** —— 流式时那半句通告早就拼进气泡了,
|
|
361
|
+
拿 Text 覆盖一次才算真的「没混在回复里」(覆盖的是权威全文,见 setResult → rerenderAll)。 */
|
|
362
|
+
case "turn_failed":
|
|
363
|
+
if(ev.text){
|
|
364
|
+
if(typeof h.setResult === "function") h.setResult(ev.text);
|
|
365
|
+
/* 台账同源(同上:画面与台账都跟权威正文走,否则历史重画又冒出那句通告) */
|
|
366
|
+
if(ctx && typeof ctx.onResult === "function") ctx.onResult(ev.text);
|
|
367
|
+
}
|
|
368
|
+
if(ev.sessionId || ev.session_id) ctx.onSession(ev.sessionId || ev.session_id);
|
|
369
|
+
h.endThinking(); finishTools(false);
|
|
370
|
+
qaRunEnd(h);
|
|
371
|
+
ctx.onError(ev);
|
|
372
|
+
break;
|
|
311
373
|
/* 过程提示(**不是**终态事件):主进程用它说「这一轮正在自动换模型重试」这类事
|
|
312
374
|
(2026-09-21 起对话侧的模型来自配置界面的降级链,前一个没额度就自动换下一个)。
|
|
313
375
|
落成一行轻量注脚(h.note,与 system 事件同一处渲染),**不收尾**——
|
|
@@ -321,6 +383,43 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
321
383
|
detail: Object.assign({}, ev, { kind: "notice" }), status: ev.status || "" });
|
|
322
384
|
else if(typeof h.note === "function") h.note(String(ev.text || ev.notice || ""));
|
|
323
385
|
break;
|
|
386
|
+
/* 模型产出的图片(KindImage,见 internal/agent/stream.go):`text` 就是图片本体
|
|
387
|
+
`data:<media_type>;base64,…`。图算**正文的一部分**(对齐 h.appendImage 的约定:
|
|
388
|
+
插在当前正文块的打字预览之前,图后到的文字自然排在图下)。
|
|
389
|
+
⚠️ 以前这里没有分支 → 这个事件掉进 default,被落成一条「未识别事件 · image」标记行。
|
|
390
|
+
信息没丢、形态不对:`qaEventNormalize` 那边对 `assistant.message.content[]` 里的
|
|
391
|
+
image 块也是「不进对话流」的同一条口径 —— 两处一起看,别再让它退回去。
|
|
392
|
+
旧手柄没有 appendImage → 静默略过(不画错、也不留噪音)。 */
|
|
393
|
+
case "image":
|
|
394
|
+
if(ev.text && typeof h.appendImage === "function") h.appendImage(String(ev.text));
|
|
395
|
+
break;
|
|
396
|
+
/* 斜杠命令节点的生命周期帧(core 的 cli/ask.go::emitCommand:开跑一条、收尾一条)。
|
|
397
|
+
状态词表与宿主同一套取值:running / completed / cancelled / failed。
|
|
398
|
+
⚠️ 与 notice 同理,以前走 default → 标题是「未识别事件 · command」,
|
|
399
|
+
而它其实是**认得的**事件(只是本版渲染器没给形态)。 */
|
|
400
|
+
case "command": {
|
|
401
|
+
const st = String(ev.status || "");
|
|
402
|
+
const bad = (st === "cancelled" || st === "failed");
|
|
403
|
+
const label = st === "running" ? "进行中" : st === "completed" ? "已完成" : bad ? "失败" : "";
|
|
404
|
+
const title = String(ev.text || "命令");
|
|
405
|
+
if(typeof h.marker === "function")
|
|
406
|
+
h.marker({ icon: QA_V_ICONS.terminal, title, status: label, destructive: bad,
|
|
407
|
+
detail: Object.assign({}, ev, { kind: "command" }) });
|
|
408
|
+
else if(typeof h.note === "function") h.note(title);
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
/* 控制通道的失败**必须说出来**:用户点了打断 / 作答却没生效,静默就等于「按钮坏了」
|
|
412
|
+
(core 的 control.go 专发这条票)。以前它走 default(能看见,但标题是
|
|
413
|
+
「未识别事件 · control_error」,把「谁发的指令失败了」这条关键信息说反了)。 */
|
|
414
|
+
case "control_error": {
|
|
415
|
+
const why = String(ev.error || ev.reason || "");
|
|
416
|
+
const title = "控制失败" + (why ? " · " + why : "");
|
|
417
|
+
if(typeof h.marker === "function")
|
|
418
|
+
h.marker({ icon: QA_V_ICONS.alert, title, status: "failed", destructive: true,
|
|
419
|
+
detail: Object.assign({}, ev, { kind: "control_error" }) });
|
|
420
|
+
else if(typeof h.note === "function") h.note(title);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
324
423
|
case "end":
|
|
325
424
|
h.endThinking(); finishTools(true);
|
|
326
425
|
qaRunEnd(h);
|
|
@@ -345,11 +444,28 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
345
444
|
· ev.session_id = 该轮会话 id → 绑到任务上(下一轮追问续接同一会话)
|
|
346
445
|
其余收尾动作与 "end" 完全一致(收思考区/工具卡/运行胶囊 + 快照 + 状态落库)。 */
|
|
347
446
|
if(ev.text) h.setResult(ev.text);
|
|
447
|
+
/* 同上:台账也跟着权威正文走(两条路同一个理由,见 result 分支那段注释)。 */
|
|
448
|
+
if(ev.text && ctx && typeof ctx.onResult === "function") ctx.onResult(ev.text);
|
|
348
449
|
if(ev.session_id) ctx.onSession(ev.session_id);
|
|
349
450
|
h.endThinking(); finishTools(true);
|
|
350
451
|
qaRunEnd(h);
|
|
351
452
|
ctx.onDone({ ok: true, code: 0, text: ev.text || "" });
|
|
352
453
|
break;
|
|
454
|
+
/* 控制通道应答与握手行(`--events` 形态专属,见 magic-agent 的 internal/cli/events.go /
|
|
455
|
+
control.go)—— 对话稿不落笔:
|
|
456
|
+
· `ready` 是 `--events` 的握手首行(谁在跑 / 契约版本),纯 plumbing;
|
|
457
|
+
· `pong` / `interrupted` / `stopped` / `answered` / `answer_queued` 是「谁在跑」那套
|
|
458
|
+
控制通道的回执,而本应用的停止走 `--stop`、作答走 `--append`,收尾一律看
|
|
459
|
+
turn_end / result / error —— 它们到了也不改变画面。
|
|
460
|
+
都不认识就会被下面 default 落成「未识别事件」标记行,把画布糊成一片垃圾。
|
|
461
|
+
⚠️ `control_error` **不在此列**:那是「你自己发的控制指令失败了」,该看得见(走 default)。 */
|
|
462
|
+
case "ready":
|
|
463
|
+
case "pong":
|
|
464
|
+
case "interrupted":
|
|
465
|
+
case "stopped":
|
|
466
|
+
case "answered":
|
|
467
|
+
case "answer_queued":
|
|
468
|
+
break;
|
|
353
469
|
default:
|
|
354
470
|
/* 形态不认识 ≠ 内容丢掉(2026-09-28,对齐 anywhere 的 UnknownTimelineItem)。
|
|
355
471
|
原来是静默 break:引擎上了新事件类型,画面上「什么都没发生」,
|
|
@@ -364,4 +480,3 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
364
480
|
break; // 心跳 / 日志等刻意噪音:忽略
|
|
365
481
|
}
|
|
366
482
|
}
|
|
367
|
-
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/* ---------- 完整 Markdown 渲染器(专业栈:marked + DOMPurify + highlight.js)----------
|
|
2
|
+
解析:marked(GFM)——表格对齐 / 任务列表 / 删除线 / 自动链接 / 围栏语言标签,
|
|
3
|
+
远超手写正则的边界正确性(嵌套列表、松散列表、行内混排等)。
|
|
4
|
+
净化:DOMPurify——文档正文多来自网页采集(不可信源),渲染后必须消毒,
|
|
5
|
+
防脚本 / 事件属性注入;放行 title / target / align / class 等阅读必需属性。
|
|
6
|
+
高亮:highlight.js——代码块按 ```lang 自动着色(hljs.highlightElement)。
|
|
7
|
+
⚠️ 它是**按需加载**的(2026-09-19):129KB,只有真渲染出代码块时才注入,
|
|
8
|
+
见下方 loadHljs(),不要在 index.html 里同步引它。
|
|
9
|
+
保留契约:仍返回 { html, anchors }。anchors 为标题索引(level/text/id),
|
|
10
|
+
供右侧目录(.md-toc)点击定位;标题锚点 id 稳定(h-1 / h-2 …),
|
|
11
|
+
悬停显示「#」。与既有的 .md-body 版式 / .doc-reader 结构完全对接。 */
|
|
12
|
+
|
|
13
|
+
const MD_STACK_READY = (typeof marked !== "undefined") && (typeof DOMPurify !== "undefined");
|
|
14
|
+
let _mdAnchors = [], _mdHSeq = 0;
|
|
15
|
+
|
|
16
|
+
/** 取纯文本(去行内标记),用于目录标题 */
|
|
17
|
+
function mdPlainText(s){
|
|
18
|
+
return String(s||"")
|
|
19
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g,"$1")
|
|
20
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g,"$1")
|
|
21
|
+
.replace(/`([^`]*)`/g,"$1")
|
|
22
|
+
.replace(/\*\*([^*]+)\*\*/g,"$1").replace(/__([^_]+)__/g,"$1")
|
|
23
|
+
.replace(/\*([^*]+)\*/g,"$1").replace(/_([^_]+)_/g,"$1")
|
|
24
|
+
.replace(/~~([^~]+)~~/g,"$1")
|
|
25
|
+
.replace(/\s+/g," ").trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* 一次性配置 marked 渲染器:
|
|
29
|
+
- heading → 稳定锚点 id + 收集目录 + 「#」锚点(.h-anc,::before 出 # 号)
|
|
30
|
+
- code → 保留 code-lang 角标 + hljs 类,随后由 highlightElement 上色
|
|
31
|
+
- listitem → GFM 任务项改写成既有 .task-li 结构;普通项回退默认渲染
|
|
32
|
+
- link → 外链加 target=_blank rel=noopener,防钓鱼落页 */
|
|
33
|
+
if(MD_STACK_READY){
|
|
34
|
+
marked.use({
|
|
35
|
+
gfm:true,
|
|
36
|
+
breaks:false,
|
|
37
|
+
renderer:{
|
|
38
|
+
heading(tok){
|
|
39
|
+
const inner = this.parser.parseInline(tok.tokens||[]);
|
|
40
|
+
const id = "h-" + (++_mdHSeq);
|
|
41
|
+
_mdAnchors.push({ level:tok.depth, text:mdPlainText(tok.text), id });
|
|
42
|
+
return `<h${tok.depth} id="${id}">${inner}<a class="h-anc" href="#${id}" title="定位此标题" aria-hidden="true"></a></h${tok.depth}>\n`;
|
|
43
|
+
},
|
|
44
|
+
code(tok){
|
|
45
|
+
const lang = String(tok.lang||"").split(/\s+/)[0];
|
|
46
|
+
const cls = "hljs" + (lang ? " language-" + lang : "");
|
|
47
|
+
// 转义代码正文:既保证 <tag> 原样显示,也避免消毒阶段误删
|
|
48
|
+
const body = String(tok.text||"")
|
|
49
|
+
.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
50
|
+
return `<pre>${lang?`<span class="code-lang">${lang}</span>`:""}<code class="${cls}">${body}</code></pre>\n`;
|
|
51
|
+
},
|
|
52
|
+
listitem(tok){
|
|
53
|
+
if(tok.task){
|
|
54
|
+
const body = this.parser.parse(tok.tokens||[], !!tok.loose);
|
|
55
|
+
return `<li class="task-li${tok.checked?" done":""}"><span class="bx">✓</span><span class="tx">${body}</span></li>\n`;
|
|
56
|
+
}
|
|
57
|
+
return false; // 回退 marked 默认(保留嵌套 <ul>/<ol>)
|
|
58
|
+
},
|
|
59
|
+
link(tok){
|
|
60
|
+
const text = this.parser.parseInline(tok.tokens||[]);
|
|
61
|
+
const href = String(tok.href||"");
|
|
62
|
+
const ti = tok.title ? ` title="${tok.title}"` : "";
|
|
63
|
+
const ext = /^https?:/i.test(href);
|
|
64
|
+
/* 本地文件链接(2026-10-14 用户:「点击不了」)——
|
|
65
|
+
引擎回复里给交付物写的就是 `[01-draft-concept.png](file:///Users/…png)`,
|
|
66
|
+
而 DOMPurify 的协议白名单(默认只放行 http/https/mailto/tel/cid/xmpp)
|
|
67
|
+
会**连 href 一起摘掉** file: —— 落进 DOM 的就是一枚没有地址的 <a>,
|
|
68
|
+
点它什么都不会发生(这正是用户看到的那一幕)。
|
|
69
|
+
而「放行 href」更糟:工作台主窗口没有 will-navigate 拦截,默认行为是把
|
|
70
|
+
**整个窗口**导航走 —— 点一下工作台就变成一张图(别处已警告过这个坑)。
|
|
71
|
+
所以本地链接一律**不带 href**,地址放进 data-local,由渲染层接管
|
|
72
|
+
(点击分流见 05-task-list-render.js 的委托)。data-* 消毒默认放行,不必动白名单。 */
|
|
73
|
+
if(!/^(?:https?:|mailto:|tel:|#)/i.test(href)){
|
|
74
|
+
return `<a data-local="${escHtml(href)}"${ti}>${text}</a>`;
|
|
75
|
+
}
|
|
76
|
+
return `<a href="${href}"${ext?' target="_blank" rel="noopener"':""}${ti}>${text}</a>`;
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 手写兜底:vendor 未加载时仍能阅读(仅线性块级,极端降级) */
|
|
83
|
+
function mdFallback(src){
|
|
84
|
+
const esc = (s)=>String(s??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
85
|
+
let html = "", inCode = false, buf = [];
|
|
86
|
+
const flushPara = ()=>{ if(buf.length){ html += "<p>" + esc(buf.join(" ")) + "</p>"; buf = []; } };
|
|
87
|
+
String(src||"").split(/\r?\n/).forEach((ln)=>{
|
|
88
|
+
if(/^\s*```/.test(ln)){ flushPara(); if(inCode){ html += `<pre><code>${esc(buf.join("\n"))}</code></pre>`; buf=[]; } inCode=!inCode; return; }
|
|
89
|
+
if(inCode){ buf.push(ln); return; }
|
|
90
|
+
const hm = /^(#{1,6})\s+(.*)$/.exec(ln);
|
|
91
|
+
if(hm){ flushPara(); const lv=hm[1].length, id="h-"+(++_mdHSeq);
|
|
92
|
+
_mdAnchors.push({level:lv,text:mdPlainText(hm[2]),id});
|
|
93
|
+
html += `<h${lv} id="${id}">${esc(hm[2])}</h${lv}>`; return; }
|
|
94
|
+
if(/^\s*$/.test(ln)){ flushPara(); return; }
|
|
95
|
+
buf.push(ln.trim());
|
|
96
|
+
});
|
|
97
|
+
flushPara();
|
|
98
|
+
return html;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 把「独占一行的图片路径」变成 markdown 图片,让对话里**直接看到图**(2026-09-19)。
|
|
102
|
+
*
|
|
103
|
+
* 为什么需要:引擎产出/引用的截图通常写成**一行绝对路径**(或本项目入库时的 `[图片] <路径>`),
|
|
104
|
+
* 不是 markdown —— 不转的话对话里只有一行字,看不到图;而飞书那边的完成回执也会把同一批图
|
|
105
|
+
* 上传成内联图(见 feishu-bridge.cjs::extractImageRefs),两边口径对齐。
|
|
106
|
+
*
|
|
107
|
+
* ⚠️ 只认**独占一行**的绝对路径 + 常见图片扩展名:
|
|
108
|
+
* · 不碰行内路径(正文里提到 `src/js/xxx.png` 不该变成图);
|
|
109
|
+
* · 路径里不含空格 / 引号 / 括号(含了就不转,免得把 markdown 语法拼坏);
|
|
110
|
+
* · alt 也填路径 —— 图加载不出来时还能看见它指向哪。 */
|
|
111
|
+
function mdLocalizeImagePaths(src){
|
|
112
|
+
const EX = "png|jpe?g|gif|webp|bmp";
|
|
113
|
+
return String(src||"")
|
|
114
|
+
// 本项目入库时给飞书图片的写法:`[图片] /abs/x.png`
|
|
115
|
+
.replace(new RegExp("^[ \\t]*\\[图片\\][ \\t]*(/\\S+\\.(?:" + EX + "))[ \\t]*$", "gim"),
|
|
116
|
+
(_a, p) => ``)
|
|
117
|
+
// 裸路径独占一行
|
|
118
|
+
.replace(new RegExp("^[ \\t]*(/[^\\s`'\"()<>]+\\.(?:" + EX + "))[ \\t]*$", "gim"),
|
|
119
|
+
(_a, p) => ``);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 渲染 Markdown → { html, anchors } */
|
|
123
|
+
function mdToHtml(src){
|
|
124
|
+
_mdAnchors = []; _mdHSeq = 0;
|
|
125
|
+
src = mdLocalizeImagePaths(src);
|
|
126
|
+
if(!MD_STACK_READY){ return { html: mdFallback(src), anchors: _mdAnchors }; }
|
|
127
|
+
let raw;
|
|
128
|
+
try{
|
|
129
|
+
raw = marked.parse(String(src||""));
|
|
130
|
+
}catch(e){
|
|
131
|
+
console.warn("[md] marked 解析失败,回退简易渲染:", e && e.message);
|
|
132
|
+
return { html: mdFallback(src), anchors: _mdAnchors };
|
|
133
|
+
}
|
|
134
|
+
// 消毒:放行阅读必需的属性 / 标签;禁 script、事件属性、javascript: 协议
|
|
135
|
+
const html = DOMPurify.sanitize(raw, {
|
|
136
|
+
ADD_ATTR: ["target", "rel", "align", "class", "id", "title", "disabled", "checked", "type"],
|
|
137
|
+
FORBID_TAGS: ["script", "style", "iframe", "form", "input"],
|
|
138
|
+
FORBID_ATTR: ["onerror", "onload", "onclick", "style"],
|
|
139
|
+
});
|
|
140
|
+
return { html, anchors: _mdAnchors };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 只取 HTML(兼容旧调用) */
|
|
144
|
+
function mdToHtmlOnly(src){ return mdToHtml(src).html; }
|
|
145
|
+
|
|
146
|
+
/* ---------- highlight.js 按需加载(2026-09-19)----------
|
|
147
|
+
129KB,比 marked + purify + floating-ui 加起来还大 —— 但只有真渲染出代码块时才用得到。
|
|
148
|
+
原来它在 index.html 里同步加载,每次启动都白解析一遍。现在首次需要时才注入;
|
|
149
|
+
注入完成前代码块以纯文本呈现(下面的 typeof hljs 分支本来就兜住了,不影响功能)。
|
|
150
|
+
流式渲染每闭合一段都会调一次 highlightIn → 加载期间可能登记多个根节点,
|
|
151
|
+
用 Set 去重,加载完统一补扫一遍。 */
|
|
152
|
+
let _hljsLoading = false;
|
|
153
|
+
const _hljsPending = new Set();
|
|
154
|
+
function loadHljs(){
|
|
155
|
+
if(typeof hljs !== "undefined" || _hljsLoading) return;
|
|
156
|
+
_hljsLoading = true;
|
|
157
|
+
const s = document.createElement("script");
|
|
158
|
+
/* 高亮库的地**由宿主定**:内核自己的目录不一定是页面的相对根
|
|
159
|
+
(PC 宿主在 /pc/ 下、移动端在 /m/ 下、magic-test 是整包拼成一个 app.js)——
|
|
160
|
+
宿主没给就落回默认相对路径 `vendor/highlight.min.js`(magic-test 那种布局正是这样)。 */
|
|
161
|
+
s.src = (typeof window !== "undefined" && window.QA_HLJS_URL) || "vendor/highlight.min.js";
|
|
162
|
+
s.onload = () => {
|
|
163
|
+
_hljsLoading = false;
|
|
164
|
+
_hljsPending.forEach((r)=>{ try{ highlightIn(r); }catch(e){ /* 节点可能已卸载,忽略 */ } });
|
|
165
|
+
_hljsPending.clear();
|
|
166
|
+
};
|
|
167
|
+
s.onerror = () => { _hljsLoading = false; }; // 加载失败就一直纯文本,不影响功能
|
|
168
|
+
document.head.appendChild(s);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 对已插入 DOM 的代码块执行语法高亮;hljs 还没就绪就先登记,加载完补扫。
|
|
172
|
+
* ⚠️ 先查有没有代码块再决定要不要惊动 129KB —— 渲染普通文档(无 ``` 块)时
|
|
173
|
+
* highlightIn 照样会被调一次,不能让它把 highlight.js 白拉下来。
|
|
174
|
+
* ⚠️ mermaid 块排在 hljs 之前处理:它有自己的渲染链路,而且是异步的,
|
|
175
|
+
* 不能等 hljs 就绪(两者互不依赖)。 */
|
|
176
|
+
function highlightIn(root){
|
|
177
|
+
if(!root) return;
|
|
178
|
+
mermaidIn(root);
|
|
179
|
+
const blocks = root.querySelectorAll("pre code");
|
|
180
|
+
if(!blocks.length) return;
|
|
181
|
+
if(typeof hljs === "undefined"){ _hljsPending.add(root); loadHljs(); return; }
|
|
182
|
+
blocks.forEach((el)=>{
|
|
183
|
+
if(el.dataset.hl) return;
|
|
184
|
+
try{ hljs.highlightElement(el); el.dataset.hl = "1"; }catch(e){ /* 未知语言静默跳过 */ }
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ---------- mermaid 流程图按需渲染(2026-10-10 用户「markdown能有流程图吗」)----------
|
|
189
|
+
产品文档的「## 功能流程」段是 ```mermaid 围栏(源码由 requirements.cjs::renderFlowMermaid
|
|
190
|
+
拼出来,保证语法合法)。这里把它渲染成图 —— 与 highlight.js 同一套「按需 + 可失败」纪律。
|
|
191
|
+
|
|
192
|
+
⚠️ 为什么走 CDN 而不是塞进 vendor/:mermaid 单文件 2MB+(highlight.js 才 129KB),
|
|
193
|
+
为偶尔看一眼流程图把它打进包不划算。它是**渐进增强**:加载成功出图,
|
|
194
|
+
失败 / 离线就**保留那块代码** —— mermaid 源码本身是可读的,读者照样能看懂流程,
|
|
195
|
+
不会像图片那样变成一个红叉。取舍与 loadWebFonts(1.5s 超时回落本地字体栈)一致。
|
|
196
|
+
⚠️ 渲染出的 SVG 由 mermaid 自己生成,**不经过 DOMPurify** ——
|
|
197
|
+
所以 securityLevel 必须保持 "strict"(它会自行消毒),不要开 htmlLabels。 */
|
|
198
|
+
const MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
|
|
199
|
+
let _mmLoading = false, _mmReady = false, _mmSeq = 0;
|
|
200
|
+
const _mmPending = new Set();
|
|
201
|
+
|
|
202
|
+
/** 对已插入 DOM 的根节点里的 ```mermaid 块出图(幂等:同一块只处理一次) */
|
|
203
|
+
function mermaidIn(root){
|
|
204
|
+
if(!root) return;
|
|
205
|
+
const blocks = root.querySelectorAll("pre > code.language-mermaid, pre > code.lang-mermaid");
|
|
206
|
+
if(!blocks.length) return;
|
|
207
|
+
if(_mmReady){ try{ renderMermaidBlocks(root); }catch(e){ /* 单块失败不影响别的块 */ } return; }
|
|
208
|
+
_mmPending.add(root);
|
|
209
|
+
if(_mmLoading) return;
|
|
210
|
+
_mmLoading = true;
|
|
211
|
+
const s = document.createElement("script");
|
|
212
|
+
s.src = MERMAID_CDN;
|
|
213
|
+
s.onload = () => {
|
|
214
|
+
_mmLoading = false; _mmReady = true;
|
|
215
|
+
/* 主题跟着壳走:中性主题的连线是深灰,落在暗色卡片上会直接看不见(见 app.css 的 .md-mermaid)。
|
|
216
|
+
⚠️ palette 只在**首次加载**时定;不刷新就切明暗的话,已出的图保持旧配色
|
|
217
|
+
(图自带底色,仍可读)—— 为这点小事把已渲染的图全部重跑一遍不值。 */
|
|
218
|
+
const dark = (document.documentElement.getAttribute("data-theme") === "dark");
|
|
219
|
+
/* fontSize:13 —— mermaid 默认 16px,图会明显偏大(用户 2026-10-10:「显示的太大了」)。
|
|
220
|
+
13px 与正文的 caption 档接近,图里的字仍然清楚,整张图小一圈。 */
|
|
221
|
+
try{ window.mermaid.initialize({ startOnLoad:false, securityLevel:"strict",
|
|
222
|
+
theme: dark ? "dark" : "neutral", themeVariables: { fontSize: "13px" } }); }
|
|
223
|
+
catch(e){ /* 初始化失败 → 后面每个 render 各自兜住,退化成代码块 */ }
|
|
224
|
+
_mmPending.forEach((r)=>{ try{ renderMermaidBlocks(r); }catch(e){ /* 节点可能已卸载 */ } });
|
|
225
|
+
_mmPending.clear();
|
|
226
|
+
};
|
|
227
|
+
s.onerror = () => { _mmLoading = false; }; // 加载失败:一直保留代码块,不影响阅读
|
|
228
|
+
document.head.appendChild(s);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 把 root 里还没出图的 ```mermaid 块替换成渲染结果。
|
|
232
|
+
* ⚠️ mermaid 的渲染 id 只能含 [A-Za-z0-9_-] 且**不能重复**(重复会抛),故用自增序号;
|
|
233
|
+
* 每块的状态记在 pre.dataset.mmState 上,避免流式渲染时同一块被反复处理。 */
|
|
234
|
+
function renderMermaidBlocks(root){
|
|
235
|
+
const blocks = root.querySelectorAll("pre > code.language-mermaid, pre > code.lang-mermaid");
|
|
236
|
+
blocks.forEach((codeEl)=>{
|
|
237
|
+
const pre = codeEl.parentElement;
|
|
238
|
+
if(!pre || pre.dataset.mmState) return;
|
|
239
|
+
pre.dataset.mmState = "pending";
|
|
240
|
+
const src = codeEl.textContent || "";
|
|
241
|
+
const id = "mm-" + (++_mmSeq);
|
|
242
|
+
Promise.resolve()
|
|
243
|
+
.then(()=> window.mermaid.render(id, src))
|
|
244
|
+
.then((res)=>{
|
|
245
|
+
const box = document.createElement("div");
|
|
246
|
+
box.className = "md-mermaid";
|
|
247
|
+
box.innerHTML = (res && res.svg) || "";
|
|
248
|
+
pre.replaceWith(box);
|
|
249
|
+
})
|
|
250
|
+
.catch(()=>{ pre.dataset.mmState = "failed"; }); // 出图失败 → 原地留代码块,不阻断阅读
|
|
251
|
+
});
|
|
252
|
+
}
|