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
|
@@ -1,880 +0,0 @@
|
|
|
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()); }
|
|
50
|
-
function qaScrollIntoView(){
|
|
51
|
-
if(qaPaintBg) return; // 后台 run 的增量不许拽动前台画布(见 qaPaintBg)
|
|
52
|
-
const s = $("#qa-stream"); if(!s) return;
|
|
53
|
-
s.scrollTop = s.scrollHeight; // 卡片固定撑满、自身即滚动容器(滚动条已隐藏),永远贴底
|
|
54
|
-
}
|
|
55
|
-
/** 对话头像(用户 2026-09-17:「用户因为没用头像应该显示一个1个超,bot这边应该用现在的
|
|
56
|
-
* 应用图标大喵标识」):
|
|
57
|
-
* · 用户侧没头像 → 显示 1 个字 = 左下角显示名的首字(「超哥」→「超」,取不到就「超」);
|
|
58
|
-
* · bot 侧 → 当前应用图标 assets/icon.png(大喵);
|
|
59
|
-
* · **审核用户**(2026-09-20 加)→ 一枚「审」字头像(琥珀底),与作者、引擎都区分开 ——
|
|
60
|
-
* 它代表的是「审查轮的结论说话人」,不是需求作者本人(用户原话:
|
|
61
|
-
* 「验证不通过的返回建议 再代表审核用户发送到对话中」)。
|
|
62
|
-
* 实时渲染(qaRenderMine / qaRenderAnswer)与派发快照(qaReqConvMsg)共用这一份标记,
|
|
63
|
-
* 否则快照里的头像会跟实时那份走形。 */
|
|
64
|
-
function qaUserInitial(){
|
|
65
|
-
const el = $("#nu-name");
|
|
66
|
-
const nm = ((el && el.textContent) || "").trim();
|
|
67
|
-
return (nm || "超").slice(0, 1);
|
|
68
|
-
}
|
|
69
|
-
/* **数字分身的头像图标**(2026-09-21 用户:「应该使用1个机器人图标头像吧」):
|
|
70
|
-
一枚简笔机器人头 —— 天线 + 圆角方脸 + 两只眼 + 一道嘴 + 两侧耳线;`currentColor` 上色,
|
|
71
|
-
颜色与底由 CSS 给(`.qa-av-sim`,朱砂实底 + 白线)。
|
|
72
|
-
为什么用机器人图标而不是「分」字:它是**一个引擎在演人**,机器人头一眼说明「这不是真人」;
|
|
73
|
-
而「分」字第一眼看不出是什么,还得配说明。 */
|
|
74
|
-
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">'
|
|
75
|
-
+ '<path d="M12 6.7V4.5"/><circle cx="12" cy="3.3" r="1.15" fill="currentColor" stroke="none"/>'
|
|
76
|
-
+ '<rect x="4.7" y="6.7" width="14.6" height="11.9" rx="3.5"/>'
|
|
77
|
-
+ '<path d="M3.4 11.6v2.6M20.6 11.6v2.6"/>'
|
|
78
|
-
+ '<circle cx="9.4" cy="11.9" r="1.25" fill="currentColor" stroke="none"/>'
|
|
79
|
-
+ '<circle cx="14.6" cy="11.9" r="1.25" fill="currentColor" stroke="none"/>'
|
|
80
|
-
+ '<path d="M9.7 15.6h4.6"/></svg>';
|
|
81
|
-
|
|
82
|
-
function qaAvatarHtml(role){
|
|
83
|
-
if(role === "mine") return `<span class="qa-av qa-av-txt" aria-hidden="true">${escHtml(qaUserInitial())}</span>`;
|
|
84
|
-
if(role === "review") return `<span class="qa-av qa-av-txt qa-av-rev" aria-hidden="true">审</span>`;
|
|
85
|
-
/* **数字分身**(2026-09-21 用户:「数字分身在右边显示专门的头像即可 不需要标注1个模拟用户的标签」→
|
|
86
|
-
「应该使用1个机器人图标头像吧」):它演的是用户,但不是用户本人 —— 所以既不复用用户头像
|
|
87
|
-
(那会让人以为是我自己发的),也不只挂一枚「模拟用户」小标(标签挡在气泡里,读起来像注释);
|
|
88
|
-
给它一枚**自己的**机器人头像(同上 QA_AV_SIM_SVG)。
|
|
89
|
-
⚠️ 与「审」同一条做法:头像由这里统一给,别在业务代码里手搓 span。 */
|
|
90
|
-
if(role === "sim") return `<span class="qa-av qa-av-sim" aria-hidden="true">${QA_AV_SIM_SVG}</span>`;
|
|
91
|
-
return `<span class="qa-av"><img class="qa-av-img" src="assets/icon.png" alt="掌天瓶" draggable="false"></span>`;
|
|
92
|
-
}
|
|
93
|
-
/* **引擎名小标**(2026-09-22 用户:「头像旁边显示引擎名字」;同日追加「名字显示的大一点
|
|
94
|
-
再加上模型显示」→ 文案 = 「引擎 · 模型」,字号见 app.css M8):左侧消息头像独占一行(见文末 M7)
|
|
95
|
-
之后,头像那一行右侧空着 —— 把「这一条是哪个引擎、哪个模型说的」挂在头像右边。
|
|
96
|
-
这里只负责吐节点;**挂不挂、挂哪一档**由渲染层决定(右侧三档不挂:它们靠头像字 / 抬头点明身份,
|
|
97
|
-
见 app.css 的 `.qa-who` 只认左侧形状)。空引擎名 → 空串:宁可没有,也不写「未知」占位。
|
|
98
|
-
模型存的是 **id**,显示走 qaModelName(与「生成中」胶囊同一套分工:名字只活在展示层)。 */
|
|
99
|
-
function qaWhoLabel(engine, model){
|
|
100
|
-
const nm = String(engine == null ? "" : engine).trim();
|
|
101
|
-
if(!nm) return "";
|
|
102
|
-
const md = String(model == null ? "" : model).trim();
|
|
103
|
-
return md ? `${nm} · ${qaModelName(md, nm)}` : nm;
|
|
104
|
-
}
|
|
105
|
-
function qaWhoHtml(engine, model){
|
|
106
|
-
const lb = qaWhoLabel(engine, model);
|
|
107
|
-
return lb ? `<span class="qa-who">${escHtml(lb)}</span>` : "";
|
|
108
|
-
}
|
|
109
|
-
function qaRenderMine(text, atts){
|
|
110
|
-
const s = $("#qa-stream"); if(!s) return;
|
|
111
|
-
s.hidden = false;
|
|
112
|
-
const div = document.createElement("div");
|
|
113
|
-
div.className = "qa-msg qa-mine";
|
|
114
|
-
div.innerHTML = `<div class="qa-ico">${qaAvatarHtml("mine")}</div><div class="qa-bd"><div class="qa-t md-body"></div></div>`;
|
|
115
|
-
// 用户消息按 markdown 渲染:marked + DOMPurify(沿用产品文档同一管线,输入是用户可控,
|
|
116
|
-
// 但消毒仍保留以防恶意粘贴)。
|
|
117
|
-
const host = div.querySelector(".qa-t");
|
|
118
|
-
/* 正文 + 正文里那些截图路径一起落笔:需求正文(`[粘贴的截图]` + 绝对路径)与飞书带图消息
|
|
119
|
-
(`[图片] <路径>`)的图**走不了附件通道**,只能把路径写进正文 —— 渲染时把它们摘出来
|
|
120
|
-
画成缩略图,别让用户对着一串 /Users/… 猜自己发了什么(见 19-attachments 的 qaShotsMount)。
|
|
121
|
-
代码块的高亮补扫也在它里面。 */
|
|
122
|
-
qaShotsMount(host, text);
|
|
123
|
-
// 随消息发出的截图(只有图没文字时 host 为空,此时这一行就是气泡的全部内容)。
|
|
124
|
-
// 用 DOM 拼节点而不是拼 HTML 字符串:缩略图地址来自落盘返回值,
|
|
125
|
-
// 走 setAttribute 天然免去一次转义,也让「没拿到地址就跳过」这种判断更直白。
|
|
126
|
-
if(atts && atts.length){
|
|
127
|
-
const row = document.createElement("div");
|
|
128
|
-
row.className = "qa-atts";
|
|
129
|
-
atts.forEach((a)=>{
|
|
130
|
-
const u = attThumbUrl(a);
|
|
131
|
-
if(!u) return;
|
|
132
|
-
row.appendChild(qaThumbNode(u, (a.name || "截图") + " · " + attSize(a.size), a.name || "截图"));
|
|
133
|
-
});
|
|
134
|
-
if(row.children.length) host.appendChild(row);
|
|
135
|
-
}
|
|
136
|
-
/* 长消息折叠(2026-09-28,对齐 anywhere 的 CollapsibleUserMessage):用户在这个工作台里
|
|
137
|
-
最常干的事就是把整份需求/上下文粘进来,一条消息能把画布顶掉两屏 —— anywhere 给用户消息
|
|
138
|
-
的解法是「超阈值就截断 + 底部渐隐 + 一枚展开全文(内容一个字不删,只改可见高度)」。
|
|
139
|
-
⚠️ 必须放在**附件之后**:判据看的是渲染后的 DOM(host 里有没有 `.qa-atts`),
|
|
140
|
-
带图的消息不折 —— 否则「我特意要你看的图」会被一起折掉(见 29-msg-variants 的 qaFoldMaybe)。
|
|
141
|
-
⚠️ 折叠态是个**类**(`.qa-fold`),落在 HTML 上 → 快照恢复后高度不会弹回去。 */
|
|
142
|
-
qaFoldMaybe(host, text);
|
|
143
|
-
s.appendChild(div);
|
|
144
|
-
/* ⚠️ 落笔即贴底(2026-09-23 用户报障:「追问问题 但是用户的问题不会触发滚动到最新
|
|
145
|
-
需要等 agent 回复才会触发」)。
|
|
146
|
-
根因:**自己这条气泡是唯一不滚的一笔** —— 引擎侧每一笔(appendText / toolCall /
|
|
147
|
-
setResult / 收尾行,见本文件各处 qaScrollIntoView)与数字分身那枚气泡
|
|
148
|
-
(24-agent-test 的 atcTwinMsg)落笔后都会贴底,只有这里没有。
|
|
149
|
-
于是「上一轮答得长、画布停在中间」时,刚发出去的那条整条落在折线以下 —— 用户看不见
|
|
150
|
-
自己刚问的话,得等引擎吐出第一段正文(09-ask-event 每次事件末尾那次滚动)才被动拽到底。
|
|
151
|
-
排队那条路径(qaMsgQueue)表现一样:轮到它自动提交时同样只有这条不滚,观感还是
|
|
152
|
-
「我的问题没滚过去、等回复才滚」。
|
|
153
|
-
⚠️ 这一行是**唯一**该滚的地方:qaRenderMine 就是「自己说的话落进画布」的唯一出口,
|
|
154
|
-
放在这里,实时发送 / 排队提交 / 派发快照三条路径一并贴底;放到调用方(qaSend)去补,
|
|
155
|
-
下一次多一条调用路径就又会漏。 */
|
|
156
|
-
qaScrollIntoView();
|
|
157
|
-
}
|
|
158
|
-
/** 「引擎 · 模型 · 生成中」= 1 枚胶囊(用户 2026-09-17):
|
|
159
|
-
* 转圈 + 文字同装一枚圆角胶囊(.qa-run-pill),实时生成行与派发快照的「生成中」状态行
|
|
160
|
-
* 共用这一份标记 —— 两处各写一份必然走形(一处改了另一处忘)。
|
|
161
|
-
* 文字仍走 qaModelName(显示名),`-m` 用的是 id,与全链路的分工一致。 */
|
|
162
|
-
function qaRunPillHtml(text){
|
|
163
|
-
return `<span class="qa-run-pill"><span class="qa-spinner"></span>`
|
|
164
|
-
+ `<span class="qa-run-t">${escHtml(text)}</span></span>`;
|
|
165
|
-
}
|
|
166
|
-
function qaRenderAnswer(host){
|
|
167
|
-
// 创建一条 AI 应答消息(思考区 + 工具卡区 + 正文区 + 运行态行),返回操作句柄
|
|
168
|
-
// host = 这一轮的落笔处(可见画布 #qa-stream,或后台 run 的 holder,见 qaRunHost)——
|
|
169
|
-
// 绝不能写死 #qa-stream:生成中切到别的会话时,这一轮的增量必须落在**自己**的画布里。
|
|
170
|
-
const s = host || $("#qa-stream"); if(!s) return null;
|
|
171
|
-
if(s.hidden) s.hidden = false;
|
|
172
|
-
const div = document.createElement("div");
|
|
173
|
-
div.className = "qa-msg";
|
|
174
|
-
div.innerHTML = `<div class="qa-ico">${qaAvatarHtml("ai")}</div>
|
|
175
|
-
<div class="qa-bd">
|
|
176
|
-
<div class="qa-think" hidden>
|
|
177
|
-
<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>
|
|
178
|
-
<div class="qa-think-bd"></div>
|
|
179
|
-
</div>
|
|
180
|
-
<div class="qa-tools" hidden>
|
|
181
|
-
<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>
|
|
182
|
-
<div class="qa-tools-bd"></div>
|
|
183
|
-
</div>
|
|
184
|
-
<div class="qa-t streaming md-body"></div>
|
|
185
|
-
</div>`;
|
|
186
|
-
/* 头像右边默认挂**当前引擎 · 当前模型**(用户 2026-09-22:「头像旁边显示引擎名字」+
|
|
187
|
-
「再加上模型显示」)。模型初值与「生成中」胶囊同源(`qaModel` = 链首 / 任务自定)。
|
|
188
|
-
⚠️ 这是默认值:说话人不是当前引擎时由调用方用句柄的 `setWho` 改写
|
|
189
|
-
(派发轮 = 该需求的引擎 / 模型,见 13-dispatch-stream;被测引擎 = 事件里的 p.engine,见 24-agent-test);
|
|
190
|
-
主进程降级换模型时由 `start` 事件改写(09-ask-event,与胶囊同一时机)。
|
|
191
|
-
⚠️ 右侧三档不显示它:审查轮会把 `.qa-ico` 整个换成「审」头像(这枚小标随之被替换掉),
|
|
192
|
-
且 `.qa-who` 的样式只认左侧形状(见 app.css)。 */
|
|
193
|
-
const icoEl = div.querySelector(".qa-ico");
|
|
194
|
-
if(icoEl) icoEl.insertAdjacentHTML("beforeend", qaWhoHtml(qaEngine, qaModel));
|
|
195
|
-
s.appendChild(div);
|
|
196
|
-
|
|
197
|
-
/* ===== 工具调用统计行(Codex 体验)=====
|
|
198
|
-
折叠条显示「工具调用 · N个 · X.Xs」,点击展开看组里的工具卡。
|
|
199
|
-
⚠️ 组里放**没有 description** 的调用(2026-09-18),外加**超编的**带 description 的独立卡
|
|
200
|
-
(正文只留最近 5 张,更早的收编进组,见 ALONE_KEEP / groupAdd);所以这里的 N 数的是
|
|
201
|
-
「组里实际有几张卡」,与正文里那几张独立卡无关(2026-09-20)。
|
|
202
|
-
appendTool 首次调用即进入 live:绿点 + 调用数实时跳动,
|
|
203
|
-
工具默认收起(live 也只亮指示,不自动展开;用户手动开合过则尊重用户);
|
|
204
|
-
endTools 定格为「已调用 · N个 · X.Xs」并维持收起 —— 之后用户仍可点开回看。 */
|
|
205
|
-
const tools = div.querySelector(".qa-tools");
|
|
206
|
-
const toolsTt = tools.querySelector(".tt");
|
|
207
|
-
const toolsCountEl = tools.querySelector(".qa-tools-count");
|
|
208
|
-
const toolsDur = tools.querySelector(".qa-tools-dur");
|
|
209
|
-
const toolsBd = tools.querySelector(".qa-tools-bd");
|
|
210
|
-
let toolsStart = 0, toolsTimer = null, toolsNum = 0;
|
|
211
|
-
const toolsTouched = ()=> tools.dataset.touched === "1";
|
|
212
|
-
function toolsBegin(){
|
|
213
|
-
tools.hidden = false;
|
|
214
|
-
tools.classList.add("live");
|
|
215
|
-
toolsTt.textContent = "工具调用中";
|
|
216
|
-
/* 机器内部活动首次显形 → 把左边那条「机器侧」消息也显出来(见 qaTraceToLeft)。
|
|
217
|
-
没做过拆分时 __traceWrap 是 undefined,这一行就是空操作。 */
|
|
218
|
-
if(div.__traceWrap) div.__traceWrap.hidden = false;
|
|
219
|
-
if(!toolsStart){
|
|
220
|
-
toolsStart = Date.now();
|
|
221
|
-
}
|
|
222
|
-
if(!toolsTimer) toolsTimer = setInterval(()=>{
|
|
223
|
-
toolsDur.hidden = false;
|
|
224
|
-
toolsDur.textContent = ((Date.now() - toolsStart) / 1000).toFixed(1) + "s";
|
|
225
|
-
}, 100);
|
|
226
|
-
}
|
|
227
|
-
function setToolsCount(n){
|
|
228
|
-
if(!toolsStart) toolsBegin();
|
|
229
|
-
toolsNum = n;
|
|
230
|
-
toolsCountEl.textContent = " · " + n + "个";
|
|
231
|
-
qaScrollIntoView();
|
|
232
|
-
}
|
|
233
|
-
function toolsEnd(){
|
|
234
|
-
if(!toolsStart) return;
|
|
235
|
-
clearInterval(toolsTimer); toolsTimer = null;
|
|
236
|
-
tools.classList.remove("live");
|
|
237
|
-
toolsTt.textContent = "已调用工具";
|
|
238
|
-
toolsDur.hidden = false;
|
|
239
|
-
toolsDur.textContent = ((Date.now() - toolsStart) / 1000).toFixed(1) + "s";
|
|
240
|
-
if(!toolsTouched()) tools.classList.remove("open");
|
|
241
|
-
}
|
|
242
|
-
/* 收尾兜底(2026-09-18):这一轮结束时,画布上**还在转圈**的工具卡一律落定。
|
|
243
|
-
为什么需要它:一条事件里装了多次调用时,tool_result 只带一个 id,只有第一张卡配得上,
|
|
244
|
-
其余几张(现在都作为独立卡摆在正文流里,转圈格外扎眼)原来会永远转 —— 连同
|
|
245
|
-
「点停止」那条收尾路径(qaRunEnd 不经过 finishTools)也一起被这里收掉。
|
|
246
|
-
钩子 `__qaDone` 由 mkCard / agentCall 挂在节点上(委派卡的是带胶囊的那版);
|
|
247
|
-
快照恢复出来的历史卡没有钩子,但它们本来就不是 running,扫不到。 */
|
|
248
|
-
function settleRunningTools(ok){
|
|
249
|
-
div.querySelectorAll('.qa-tool[data-state="running"]').forEach(el=>{
|
|
250
|
-
if(typeof el.__qaDone === "function"){ try{ el.__qaDone("", ok); }catch(e){} }
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/* ===== 深度思考状态机(Codex 体验)=====
|
|
255
|
-
appendThinking 首次调用即进入 live:朱砂呼吸点 + 「思考中」+ 秒数实时跳动,
|
|
256
|
-
思考默认收起(live 也只亮指示,不自动展开;用户手动开合过则尊重用户);
|
|
257
|
-
endThinking 定格为「已深度思考 · X.Xs」并维持收起 —— 之后用户仍可点开回看。 */
|
|
258
|
-
const think = div.querySelector(".qa-think");
|
|
259
|
-
const thinkTt = think.querySelector(".tt");
|
|
260
|
-
const thinkDur = think.querySelector(".qa-think-dur");
|
|
261
|
-
const thinkBd = think.querySelector(".qa-think-bd");
|
|
262
|
-
/* ⚠️ 开合点击统一走 #qa-stream 上的**事件委托**(见 bindQaStreamDelegation):
|
|
263
|
-
快照恢复(`s.innerHTML = t.snapshot`)会把节点整个重建 —— 直接 addEventListener
|
|
264
|
-
绑在卡片上的监听随旧节点一起消失,「恢复历史对话后点工具卡/思考条没反应」
|
|
265
|
-
(2026-09-16 用户报障:WebSearch 点击无法展开)。委托挂在不会被重建的容器上,
|
|
266
|
-
恢复后的卡片照样可开合。用户手动开合过 → 记 data-touched,自动展开/收起让位。 */
|
|
267
|
-
let thinkStart = 0, thinkTimer = null;
|
|
268
|
-
const thinkTouched = ()=> think.dataset.touched === "1";
|
|
269
|
-
function thinkBegin(){
|
|
270
|
-
think.hidden = false;
|
|
271
|
-
think.classList.add("live");
|
|
272
|
-
thinkTt.textContent = "思考中";
|
|
273
|
-
/* 同 toolsBegin:内部活动首次显形 → 显出左边那条「机器侧」消息(见 qaTraceToLeft) */
|
|
274
|
-
if(div.__traceWrap) div.__traceWrap.hidden = false;
|
|
275
|
-
if(!thinkStart){
|
|
276
|
-
thinkStart = Date.now();
|
|
277
|
-
// 思考默认收起:live 态只亮「思考中」指示(灰标签 + 朱砂呼吸点),不自动展开;
|
|
278
|
-
// 用户点开回看 → 记 data-touched,之后尊重用户开合(见 bindQaStreamDelegation)。
|
|
279
|
-
}
|
|
280
|
-
if(!thinkTimer) thinkTimer = setInterval(()=>{
|
|
281
|
-
thinkDur.hidden = false;
|
|
282
|
-
thinkDur.textContent = ((Date.now() - thinkStart) / 1000).toFixed(1) + "s";
|
|
283
|
-
}, 100);
|
|
284
|
-
}
|
|
285
|
-
function thinkEnd(){
|
|
286
|
-
if(!thinkStart) return; // 从未思考过则什么都不显示
|
|
287
|
-
clearInterval(thinkTimer); thinkTimer = null;
|
|
288
|
-
think.classList.remove("live");
|
|
289
|
-
thinkTt.textContent = "已深度思考";
|
|
290
|
-
thinkDur.hidden = false;
|
|
291
|
-
thinkDur.textContent = ((Date.now() - thinkStart) / 1000).toFixed(1) + "s";
|
|
292
|
-
if(!thinkTouched()) think.classList.remove("open"); // 用户没手动开过 → 维持收起(用户点开过则保留展开)
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const run = document.createElement("div");
|
|
296
|
-
run.className = "qa-run";
|
|
297
|
-
run.innerHTML = qaRunPillHtml(`${qaEngine}${qaModel ? " · " + qaModelName(qaModel, qaEngine) : ""} · 生成中`);
|
|
298
|
-
s.appendChild(run);
|
|
299
|
-
/* ⚠️ 落笔即贴底(2026-09-23,与 qaRenderMine / qaRenderDone 同一约定)。
|
|
300
|
-
为什么必须在**这一行之后**:这条应答的立身有两块 —— 气泡节点(上面那个 div)+ 这枚
|
|
301
|
-
「生成中」胶囊行,两块加起来约 96px。它挂在**用户刚发的那条气泡之后**,用户那条贴了底、
|
|
302
|
-
这一条不贴,画布底下就留着一条空白(实测 slack=96px)→ 用户看到的仍是「没滚到最新」,
|
|
303
|
-
下一段正文到达时画面还会再往下跳一次。
|
|
304
|
-
⚠️ 闸门在 qaScrollIntoView 里(qaPaintBg:后台 run 在画时不拽动前台画布)—— 本函数的
|
|
305
|
-
`host` 可指向后台画布,但滚动对象始终是可见画布,所以这里不必自己判。 */
|
|
306
|
-
qaScrollIntoView();
|
|
307
|
-
|
|
308
|
-
/* ===== 助手消息:流式段落缓冲(Claude / Codex 风格)=====
|
|
309
|
-
模型逐 token 推送会撞碎 markdown 标签(** 加粗被切成 3 段)。
|
|
310
|
-
策略:累积整段纯文本 → 按 \n\n 切块 → 已闭合段落立即走 marked 解析 →
|
|
311
|
-
落到 DOM 的 <p>/<ul>/<pre> 节点上。**未闭合的尾部段落**用纯文本预览
|
|
312
|
-
兜底(.qa-tail 类,便于区分"已渲染"vs"打字中")。
|
|
313
|
-
流式结束 / result 全文到达时,再把尾部段落也走一次解析。 */
|
|
314
|
-
const body = div.querySelector(".qa-t");
|
|
315
|
-
const segs = []; // 已渲染的 DOM 段落节点数组
|
|
316
|
-
let pending = ""; // 尚未到达 \n\n 的尾部累积文本
|
|
317
|
-
|
|
318
|
-
function flushClosed(){
|
|
319
|
-
// 把累积文本里已闭合的段落(以 \n\n 切分)逐段解析渲染,留尾部未闭合部分在 pending
|
|
320
|
-
if(!pending) return;
|
|
321
|
-
const idx = pending.lastIndexOf("\n\n");
|
|
322
|
-
if(idx < 0) return;
|
|
323
|
-
const ready = pending.slice(0, idx);
|
|
324
|
-
pending = pending.slice(idx + 2);
|
|
325
|
-
ready.split(/\n{2,}/).forEach(chunk=>{
|
|
326
|
-
if(!chunk.trim()) return;
|
|
327
|
-
/* 一段落成:走**统一落笔处**(19-attachments::qaShotsMount)而不是直接 mdToHtmlOnly ——
|
|
328
|
-
bot 回复里带着**它看到的引用 / 它自己截的图**时,那些 /Users/…png 要变成缩略图,
|
|
329
|
-
不能当文字念(2026-09-20 用户:「用户的截图显示对了 bot回复中显示图片还不对」)。
|
|
330
|
-
没有截图路径时 qaShotsMount 内部就是 mdToHtmlOnly,语义与原来逐字相同。 */
|
|
331
|
-
const tmp = document.createElement("div");
|
|
332
|
-
qaShotsMount(tmp, chunk);
|
|
333
|
-
// 把 tmp 下的每个块级节点追加到 body(而不是覆盖 body,因为已存在历史段落)
|
|
334
|
-
[...tmp.childNodes].forEach(node=>{
|
|
335
|
-
// 去掉 streaming 类的 cursor,实际只加在最后一个未闭合段落
|
|
336
|
-
if(node.classList) node.classList.remove("streaming");
|
|
337
|
-
body.appendChild(node);
|
|
338
|
-
segs.push(node);
|
|
339
|
-
});
|
|
340
|
-
});
|
|
341
|
-
// 重新上色本轮新加的代码块(避免重复:marked 已产出 .hljs,highlightIn 会检查 dataset.hl)
|
|
342
|
-
highlightIn(body);
|
|
343
|
-
// 同步给 streaming 类(光标)只在最后一个未闭合 tail 上
|
|
344
|
-
const tail = body.querySelector(".qa-tail");
|
|
345
|
-
if(tail) tail.classList.add("streaming");
|
|
346
|
-
}
|
|
347
|
-
function renderTail(){
|
|
348
|
-
// 渲染尾部未闭合段落:纯文本预览(div 块级容器,避免被 inline 上下文挤压)
|
|
349
|
-
const old = body.querySelector(".qa-tail");
|
|
350
|
-
if(old) old.remove();
|
|
351
|
-
if(!pending) return;
|
|
352
|
-
const div = document.createElement("div");
|
|
353
|
-
div.className = "qa-tail streaming";
|
|
354
|
-
div.textContent = pending;
|
|
355
|
-
body.appendChild(div);
|
|
356
|
-
}
|
|
357
|
-
function rerenderAll(text){
|
|
358
|
-
// result/全文覆盖:重置整段 + 一次性解析(此时已知完整 markdown,无未闭合)
|
|
359
|
-
// 走统一落笔处:正文里那些截图路径(引擎回话里常见)一并变成缩略图
|
|
360
|
-
body.innerHTML = "";
|
|
361
|
-
qaShotsMount(body, text);
|
|
362
|
-
highlightIn(body);
|
|
363
|
-
pending = "";
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/* ===== 工具调用卡(Codex 体验)=====
|
|
367
|
-
toolCall(name, argsPreview, argsFull) → { done(resultText, ok), state }
|
|
368
|
-
· 运行中:实线卡片、spinner、秒数跳动,默认收起(点头部展开看输入/输出)
|
|
369
|
-
· done():状态切勾/叉、秒数定格、维持收起降调为静默单行(用户点开过则保持)
|
|
370
|
-
· 卡片插在正文(.qa-t)之前 —— 顺序即 思考 → 工具 → 正文;正文已在流式时
|
|
371
|
-
也允许工具穿插(insertBefore 保证工具行始终在正文块上方)
|
|
372
|
-
2026-09-17 重构:卡片本体抽成 mkCard(只造不插)—— toolCall(插主流)、
|
|
373
|
-
agentCall(委派卡)、subTool(嵌套进父委派卡)共用同一份结构,两处各写一份必然走形。
|
|
374
|
-
2026-09-18:toolCall 按 description 分流 —— 带 description 的独立显示、不带 description 的归组
|
|
375
|
-
(用户:「调用工具有 description 时要独立出来显示,没有的才在一起一个组显示」)。 */
|
|
376
|
-
const bd = div.querySelector(".qa-bd");
|
|
377
|
-
/* 卡片到达序号(2026-09-20):折叠组里的卡片按**到达顺序**排 —— 独立卡超编被收编进组时
|
|
378
|
-
要插回它该在的位置(组里还夹着无 description 的卡,直接 append 会把它排到整组之后)。 */
|
|
379
|
-
let toolSeq = 0;
|
|
380
|
-
/* isDesc:摘要是不是「人话」(description / command)—— 是则换正文字体渲染(见 .qa-tool-args.qa-desc)。
|
|
381
|
-
传布尔而不是靠调用方自己加类:mkCard 是唯一一处写这份标记的地方(委派卡/子工具卡都复用)。 */
|
|
382
|
-
function mkCard(name, argsPreview, argsFull, isDesc, meta){
|
|
383
|
-
const card = document.createElement("div");
|
|
384
|
-
card.className = "qa-tool";
|
|
385
|
-
card.dataset.state = "running";
|
|
386
|
-
card.dataset.seq = String(++toolSeq);
|
|
387
|
-
const started = Date.now();
|
|
388
|
-
/* 明细段统一成「代码面板帧」的形制(label 行 + 复制按钮 + 代码体,见 29-msg-variants
|
|
389
|
-
的 qaCodeFrameNode 注释):这里原来是裸 `<pre>` 加一枚 9px 小标题 —— 没有复制、
|
|
390
|
-
也不横向滚动,长输出只能靠拖选,与 anywhere 的 CodePanelFrame 差一档。
|
|
391
|
-
⚠️ 复制按钮**不带监听**:点击走 #qa-stream 事件委托(07 的 bindQaStreamDelegation),
|
|
392
|
-
否则快照恢复(`innerHTML = snapshot`)之后按钮就成了死的。 */
|
|
393
|
-
card.innerHTML = `
|
|
394
|
-
<div class="qa-tool-head">
|
|
395
|
-
<span class="qa-tool-ico">${QA_ICONS.tool}</span>
|
|
396
|
-
<span class="qa-tool-name"></span>
|
|
397
|
-
<span class="qa-tool-args"></span>
|
|
398
|
-
<span class="qa-tool-meta">
|
|
399
|
-
<span class="qa-tool-dur">0.0s</span>
|
|
400
|
-
<span class="qa-tool-st">
|
|
401
|
-
<svg class="i-ok" viewBox="0 0 24 24"><path d="M4 12.5l5 5L20 6.5"/></svg>
|
|
402
|
-
<svg class="i-err" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
|
403
|
-
</span>
|
|
404
|
-
<svg class="chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
|
|
405
|
-
</span>
|
|
406
|
-
</div>
|
|
407
|
-
<div class="qa-tool-bd">
|
|
408
|
-
<div class="qa-tool-sec v-in-sec">
|
|
409
|
-
<div class="qa-sec-hd"><span class="k">输入</span>${QA_COPY_BTN}</div>
|
|
410
|
-
<pre class="v-in"></pre>
|
|
411
|
-
</div>
|
|
412
|
-
<div class="qa-tool-sec v-sec" hidden>
|
|
413
|
-
<div class="qa-sec-hd"><span class="k">输出</span>${QA_COPY_BTN}</div>
|
|
414
|
-
<pre class="v-out"></pre>
|
|
415
|
-
</div>
|
|
416
|
-
</div>`;
|
|
417
|
-
/* 人话标题(2026-09-28,对齐 anywhere 的 timelineToolTitle):调用方给了 meta.title 时
|
|
418
|
-
行里不再顶「内部工具名 + 参数 JSON」,而是写一句人话(「运行了 ls -la」「编辑 a.ts」);
|
|
419
|
-
原始名与参数降级进 title 属性(悬停可见)与明细的「输入」段(排障照旧拿得到)。
|
|
420
|
-
⚠️ 没给 meta 的老调用方(委派卡 / 子代理卡 / 未配对结果)行为逐字不变 —— 这是新增的
|
|
421
|
-
可选形参,不是替换。 */
|
|
422
|
-
const nameEl = card.querySelector(".qa-tool-name");
|
|
423
|
-
const argsEl = card.querySelector(".qa-tool-args");
|
|
424
|
-
if(meta && meta.title){
|
|
425
|
-
nameEl.textContent = "";
|
|
426
|
-
nameEl.hidden = true;
|
|
427
|
-
argsEl.textContent = String(meta.title);
|
|
428
|
-
argsEl.classList.add("qa-desc");
|
|
429
|
-
argsEl.title = String(name || "tool")
|
|
430
|
-
+ (argsPreview || argsFull ? " · " + String(argsPreview || argsFull).slice(0, 300) : "");
|
|
431
|
-
const kind = String((meta && meta.kind) || "tool").replace(/[^a-z_]/gi, "") || "tool";
|
|
432
|
-
card.classList.add("qa-kind-" + kind);
|
|
433
|
-
card.querySelector(".qa-tool-ico").innerHTML = qaToolIconFor(kind);
|
|
434
|
-
}else{
|
|
435
|
-
nameEl.textContent = String(name || "tool");
|
|
436
|
-
argsEl.textContent = String(argsPreview || argsFull || "");
|
|
437
|
-
if(isDesc) argsEl.classList.add("qa-desc");
|
|
438
|
-
}
|
|
439
|
-
const vInText = String(argsFull || argsPreview || "");
|
|
440
|
-
if(vInText) card.querySelector(".v-in").textContent = vInText;
|
|
441
|
-
else card.querySelector(".v-in-sec").hidden = true;
|
|
442
|
-
const durEl = card.querySelector(".qa-tool-dur");
|
|
443
|
-
const timer = setInterval(()=>{
|
|
444
|
-
durEl.textContent = ((Date.now() - started) / 1000).toFixed(1) + "s";
|
|
445
|
-
}, 100);
|
|
446
|
-
// 开合点击走 #qa-stream 委托(见 bindQaStreamDelegation),此处不直接绑监听:
|
|
447
|
-
// 直接绑的监听在快照恢复(innerHTML 重建)后会丢,卡片就点不开了。
|
|
448
|
-
const api = {
|
|
449
|
-
card,
|
|
450
|
-
/* 追加一个明细段(文件变更行走这条,见 09 的 tool_use 分支)。
|
|
451
|
-
插在「输出」段之前 → 段落顺序与 anywhere 的 ToolDetailPanel 一致:输入 / 变更 / 输出。 */
|
|
452
|
-
addSection(node){
|
|
453
|
-
if(!node) return null;
|
|
454
|
-
const bdEl = card.querySelector(".qa-tool-bd");
|
|
455
|
-
bdEl.insertBefore(node, card.querySelector(".v-sec"));
|
|
456
|
-
card.classList.remove("qa-tool-bare");
|
|
457
|
-
qaScrollIntoView();
|
|
458
|
-
return node;
|
|
459
|
-
},
|
|
460
|
-
done(resultText, ok = true){
|
|
461
|
-
clearInterval(timer);
|
|
462
|
-
card.dataset.state = ok ? "ok" : "err";
|
|
463
|
-
durEl.textContent = ((Date.now() - started) / 1000).toFixed(1) + "s";
|
|
464
|
-
if(resultText != null && String(resultText).length){
|
|
465
|
-
const outSec = card.querySelector(".v-sec");
|
|
466
|
-
const outPre = card.querySelector(".v-out");
|
|
467
|
-
const rt = String(resultText);
|
|
468
|
-
outSec.hidden = false;
|
|
469
|
-
/* 结果本身是 unified diff 时走 **Diff 面板**(2026-09-28)。
|
|
470
|
-
引擎回执里常见「改完了 + 一段 diff 原文」;当普通文本摊开的话,
|
|
471
|
-
+/- 混在正文里既看不出增删也不好读。对齐 anywhere:diff 一律走 DiffPanel
|
|
472
|
-
(四列栅格 + 增删配色)。
|
|
473
|
-
⚠️ 只在**确实像** diff 时才替换:判定要同时有 `@@` 与 `---`/`+++`(见 qaLooksLikeDiff),
|
|
474
|
-
否则普通输出里以减号开头的行会被误判成一整块 diff。
|
|
475
|
-
⚠️ 原文仍留在 pre 里(只是 hidden):复制按钮取的就是 `.qa-tool-sec pre` 的文本,
|
|
476
|
-
挪走它复制按钮就瞎了。 */
|
|
477
|
-
outPre.textContent = rt;
|
|
478
|
-
const asDiff = typeof qaLooksLikeDiff === "function" && qaLooksLikeDiff(rt) && !outSec.querySelector(".qa-diff");
|
|
479
|
-
if(asDiff){
|
|
480
|
-
outPre.hidden = true;
|
|
481
|
-
const d = document.createElement("div");
|
|
482
|
-
d.className = "qa-diff";
|
|
483
|
-
d.innerHTML = qaDiffRowsHtml(qaDiffTextToRows(rt), true);
|
|
484
|
-
outSec.appendChild(d);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
/* 「无明细」退化形态(对齐 anywhere:工具没有 command/output/changes 时退化成一条
|
|
488
|
-
单行 marker,不带边框与底纹)。
|
|
489
|
-
判据取**实际渲染后**的段内容而不是入参:多段拼装(文件变更行、追加段)之后,
|
|
490
|
-
只有这时候才知道这条到底有没有东西可看。class 落在 HTML 上 → 快照可还原。 */
|
|
491
|
-
const secs = card.querySelectorAll(".qa-tool-bd > .qa-tool-sec");
|
|
492
|
-
let hasDetail = false;
|
|
493
|
-
for(const s of secs){
|
|
494
|
-
if(s.hidden) continue;
|
|
495
|
-
const pre = s.querySelector("pre");
|
|
496
|
-
if(pre ? pre.textContent.trim().length > 0 : s.childElementCount > 0){ hasDetail = true; break; }
|
|
497
|
-
}
|
|
498
|
-
card.classList.toggle("qa-tool-bare", !hasDetail);
|
|
499
|
-
if(card.dataset.touched !== "1") card.classList.remove("open");
|
|
500
|
-
qaScrollIntoView();
|
|
501
|
-
},
|
|
502
|
-
get state(){ return card.dataset.state; },
|
|
503
|
-
};
|
|
504
|
-
/* 落定钩子挂在**节点**上(2026-09-18):流收尾要能把「配不到 tool_result id 的追加卡」
|
|
505
|
-
也一起收掉 —— 一条事件装多次调用时,只有第一张挂得上 id,其余原来会永远转圈
|
|
506
|
-
(卡片从折叠组挪成独立卡后更显眼,所以这里补上)。挂 DOM 而不是闭包数组,
|
|
507
|
-
是因为收尾那一侧只有节点可拿;快照恢复出来的历史卡没有钩子 → 收尾自然跳过
|
|
508
|
-
(它们本来就不是 running,扫不到)。 */
|
|
509
|
-
card.__qaDone = api.done;
|
|
510
|
-
return api;
|
|
511
|
-
}
|
|
512
|
-
/* 每调一次 = 一次调用(「调用了多少个工具」= 画了几张卡;一条事件里装了多次调用时,
|
|
513
|
-
调用方会按 description 切成多个单元逐个调进来,见 qaToolUnits)。
|
|
514
|
-
2026-09-18 用户:「调用工具有 description 时要独立出来显示,没有的才在一起一个组显示」
|
|
515
|
-
→ 按 isDesc 分流:
|
|
516
|
-
· isDesc=true → **独立卡**(.qa-tool-alone)直接落在正文流里(组容器之外)——
|
|
517
|
-
摘要是人话(description),本来就该直接看见,没必要跟一堆参数卡一起
|
|
518
|
-
藏进「已调用工具 · N个」里;但正文只留**最近 5 张**,超编的照样收编进组
|
|
519
|
-
(2026-09-20 追加,见下 ALONE_KEEP)。
|
|
520
|
-
· isDesc=false → 并入折叠组(.qa-tools-bd),组计数 +1。
|
|
521
|
-
组头也因此改成**懒启动**:第一张无 description 的卡到达时才点亮(见 qaStreamApplyOne
|
|
522
|
-
的 tool_use 分支 —— 那里不再预先 beginTools),否则「全是 description」的一轮会留下一个 0个 的空组。
|
|
523
|
-
2026-09-20 追加「正文只留最近 5 张独立卡」:见下面 ALONE_KEEP / groupAdd。 */
|
|
524
|
-
/* ===== 正文里独立卡的保留数(2026-09-20 用户:「只显示最近的5个有 description 的,
|
|
525
|
-
其他的在工具调用中显示」)=====
|
|
526
|
-
带 description 的卡原先是**全部**独立落在正文流里,一轮长活跑下来正文被十几张卡顶得老长
|
|
527
|
-
(截图实证:折叠组「19个」下面还挂着一长串独立卡,正文反而成了主角)。现在正文只留
|
|
528
|
-
**最近 5 张**,更早的按到达顺序收编进「工具调用 · N个」折叠组 —— 组计数一并把它们算进去
|
|
529
|
-
(它们确实已经在组里了,计数不跟着加就会出现「组里 6 张、写着 1个」的假账)。
|
|
530
|
-
⚠️ 只收编 toolCall 出来的独立卡:委派卡(.qa-agent)是里程碑、卡里还挂着子代理的**实时活动**,
|
|
531
|
-
收进默认收起的组里等于把正在跑的委派藏起来 —— 不在此列。 */
|
|
532
|
-
const ALONE_KEEP = 5;
|
|
533
|
-
const aloneCards = []; // 已独立显示的 desc 卡,队首最老
|
|
534
|
-
/* 进组:按到达序号插回该在的位置(组里夹着无 description 的卡,append 会打乱先后),
|
|
535
|
-
顺手把组头点亮 / 计数 +1 —— 原先是 toolCall 里各写一份,现在只有这一处。 */
|
|
536
|
-
function groupAdd(card){
|
|
537
|
-
const seq = Number(card.dataset.seq || 0);
|
|
538
|
-
const sib = [...toolsBd.children].find(el=> Number(el.dataset.seq || 0) > seq);
|
|
539
|
-
if(sib) toolsBd.insertBefore(card, sib); else toolsBd.appendChild(card);
|
|
540
|
-
if(!toolsStart) toolsBegin(); // 启动工具调用统计行
|
|
541
|
-
toolsCountEl.textContent = " · " + (++toolsNum) + "个"; // 实时更新计数
|
|
542
|
-
}
|
|
543
|
-
function toolCall(name, argsPreview, argsFull, isDesc, meta){
|
|
544
|
-
const t = mkCard(name, argsPreview, argsFull, isDesc, meta);
|
|
545
|
-
if(isDesc){
|
|
546
|
-
t.card.classList.add("qa-tool-alone");
|
|
547
|
-
bd.insertBefore(t.card, body); // 独立显示:正文流里的一张卡
|
|
548
|
-
aloneCards.push(t.card);
|
|
549
|
-
while(aloneCards.length > ALONE_KEEP){ // 超出保留数:最老的那张收编进折叠组
|
|
550
|
-
const old = aloneCards.shift();
|
|
551
|
-
old.classList.remove("qa-tool-alone"); // 左细条是「不在组里」的标记,进组就摘掉
|
|
552
|
-
groupAdd(old);
|
|
553
|
-
}
|
|
554
|
-
}else{
|
|
555
|
-
groupAdd(t.card); // 归组显示:折叠容器内
|
|
556
|
-
}
|
|
557
|
-
qaScrollIntoView();
|
|
558
|
-
return t;
|
|
559
|
-
}
|
|
560
|
-
/* ===== ⑤ 提问卡(AskUserQuestion)=====
|
|
561
|
-
2026-09-18 用户:「返回了 askuserquestion,需要 ui 在对话框中增加选择显示 而不是显示这个 json」。
|
|
562
|
-
引擎问「你想执行哪个操作」时,原来只落成一张普通工具卡:questions/options 的 JSON 摊在
|
|
563
|
-
「输入」里,用户得自己读 JSON 再手打答案。这里换成**可点选的卡**:
|
|
564
|
-
· 单问单选 → 点一下即答(不必再点提交);
|
|
565
|
-
· 多问 / 多选 → 勾完点「提交」,答案按「题 → 选项」拼成一段回给引擎;
|
|
566
|
-
· 「其他」行 → 想选不在清单里的答案就直接写,回车/点确认即答。
|
|
567
|
-
参数 JSON 原样留在卡的 title 上(要排障时鼠标停一下就能看到),但不再摊在卡面上。
|
|
568
|
-
解析失败时调用方不会走到这里(回落普通工具卡),所以这里 qs 至少有 1 问。 */
|
|
569
|
-
/* qs = 归一化后的问题数组。两种来源共用这一种结构:
|
|
570
|
-
· tool_use 参数 → qaAskParse(要猜三种不稳定形状,老版 CLI 只有这条路)
|
|
571
|
-
· 统一 ask 事件 → qaAskFromEvent(magic-agent 2026-09-18 起归一化过的稳定形状)
|
|
572
|
-
opts = { title, sid, rid }:title 是排障用的原始载荷;sid / rid = **答案要回到哪条会话**
|
|
573
|
-
(追加通道的寻址信息,落 dataset,见下)。 */
|
|
574
|
-
function askCard(name, qs, opts){
|
|
575
|
-
const list = Array.isArray(qs) ? qs : [];
|
|
576
|
-
const o = opts || {};
|
|
577
|
-
const multi = list.length > 1 || list.some(q=>q.multiSelect);
|
|
578
|
-
const card = document.createElement("div");
|
|
579
|
-
card.className = "qa-ask";
|
|
580
|
-
card.title = String(o.title || "").slice(0, 600);
|
|
581
|
-
/* 答案要回到哪条会话 —— 落在 **dataset** 上而不是闭包里:
|
|
582
|
-
快照恢复走 `innerHTML = snapshot`,闭包随旧节点一起丢,dataset 跟着 HTML 活下来,
|
|
583
|
-
所以恢复出来的历史提问卡照样答得回去(REQ-22 锁的就是「已答态随快照恢复」)。 */
|
|
584
|
-
if(o.sid) card.dataset.sid = String(o.sid);
|
|
585
|
-
if(o.rid != null && o.rid !== "") card.dataset.rid = String(o.rid);
|
|
586
|
-
card.innerHTML = `
|
|
587
|
-
<div class="qa-ask-hd">
|
|
588
|
-
<span class="qa-ask-ico">${QA_ICONS.ask}</span>
|
|
589
|
-
<span class="qa-ask-tag"></span>
|
|
590
|
-
<span class="qa-ask-ttl">提问 · 待回答</span>
|
|
591
|
-
</div>
|
|
592
|
-
<div class="qa-ask-list"></div>
|
|
593
|
-
<div class="qa-ask-other">
|
|
594
|
-
<input type="text" placeholder="其他:不在上面选项里就直接写,回车确认">
|
|
595
|
-
<button type="button" class="qa-ask-go">确认</button>
|
|
596
|
-
</div>
|
|
597
|
-
<div class="qa-ask-ft"${multi ? "" : " hidden"}>
|
|
598
|
-
<button type="button" class="qa-ask-sub" disabled>提交</button>
|
|
599
|
-
<span class="qa-ask-hint">${list.length > 1 ? "共 " + list.length + " 问,都选完再提交" : "可多选 · 选完点提交"}</span>
|
|
600
|
-
</div>
|
|
601
|
-
<div class="qa-ask-ans" hidden><span class="k">你的回答</span><span class="v"></span></div>`;
|
|
602
|
-
/* ⚠️ 2026-09-22 审美修订:这枚标签原来**直接把引擎的工具名画在卡头上**
|
|
603
|
-
(`AskUserQuestion`,9px 等宽 + 蓝框)—— 用户看到的是英文实现细节,
|
|
604
|
-
读不出「这张卡是在等我回答」。现在标签写人话「需要你确认」,
|
|
605
|
-
工具名降级进 title(排障时鼠标一悬停仍能看到是哪个工具)。 */
|
|
606
|
-
const tagEl = card.querySelector(".qa-ask-tag");
|
|
607
|
-
if(tagEl){
|
|
608
|
-
tagEl.textContent = "需要你确认";
|
|
609
|
-
tagEl.title = "引擎提问工具:" + String(name || "AskUserQuestion");
|
|
610
|
-
}
|
|
611
|
-
const listEl = card.querySelector(".qa-ask-list");
|
|
612
|
-
list.forEach((q, qi)=>{
|
|
613
|
-
const qb = document.createElement("div");
|
|
614
|
-
qb.className = "qa-ask-qb";
|
|
615
|
-
qb.dataset.qi = String(qi);
|
|
616
|
-
qb.dataset.multi = q.multiSelect ? "1" : "0";
|
|
617
|
-
qb.dataset.q = q.question;
|
|
618
|
-
const head = document.createElement("div");
|
|
619
|
-
head.className = "qa-ask-q";
|
|
620
|
-
if(q.header){
|
|
621
|
-
const chip = document.createElement("span");
|
|
622
|
-
chip.className = "qa-ask-chip";
|
|
623
|
-
chip.textContent = q.header;
|
|
624
|
-
head.appendChild(chip);
|
|
625
|
-
}
|
|
626
|
-
head.appendChild(document.createTextNode(q.question));
|
|
627
|
-
qb.appendChild(head);
|
|
628
|
-
const opts = document.createElement("div");
|
|
629
|
-
opts.className = "qa-ask-opts";
|
|
630
|
-
q.options.forEach((o)=>{
|
|
631
|
-
const b = document.createElement("button");
|
|
632
|
-
b.type = "button";
|
|
633
|
-
b.className = "qa-ask-opt";
|
|
634
|
-
b.dataset.label = o.label;
|
|
635
|
-
b.setAttribute("role", q.multiSelect ? "checkbox" : "radio");
|
|
636
|
-
b.setAttribute("aria-checked", "false");
|
|
637
|
-
b.innerHTML = `<span class="mk"><svg viewBox="0 0 24 24"><path d="M4 12.5l5 5L20 6.5"/></svg></span>
|
|
638
|
-
<span class="tx"><span class="lb"></span><span class="ds"></span></span>`;
|
|
639
|
-
b.querySelector(".lb").textContent = o.label;
|
|
640
|
-
b.querySelector(".ds").textContent = o.description;
|
|
641
|
-
if(!o.description) b.querySelector(".ds").remove();
|
|
642
|
-
opts.appendChild(b);
|
|
643
|
-
});
|
|
644
|
-
qb.appendChild(opts);
|
|
645
|
-
listEl.appendChild(qb);
|
|
646
|
-
});
|
|
647
|
-
bd.insertBefore(card, body);
|
|
648
|
-
qaScrollIntoView();
|
|
649
|
-
let settled = false; // 这一问是否已被引擎收尾(见 done)
|
|
650
|
-
return {
|
|
651
|
-
card,
|
|
652
|
-
/* 引擎这一问的收尾(tool_result / 流结束兜底)。**卡不会因此失效**:
|
|
653
|
-
多数引擎在非交互模式下收不到回答就自己结束了,此时用户点选项仍然是把答案
|
|
654
|
-
作为追问发回去 —— 那正是这张卡存在的意义,所以这里只更新标题、不禁用。 */
|
|
655
|
-
done(){
|
|
656
|
-
if(!qaAskAnswered(card)){
|
|
657
|
-
const ttl = card.querySelector(".qa-ask-ttl");
|
|
658
|
-
if(ttl) ttl.textContent = "提问 · 待回答";
|
|
659
|
-
}
|
|
660
|
-
settled = true;
|
|
661
|
-
qaScrollIntoView();
|
|
662
|
-
},
|
|
663
|
-
/* 统一 ask 事件后到(magic-agent 会为同一问先发 tool_use、再发 ask)→ 不重画第二张卡,
|
|
664
|
-
只把「答回去要用的东西」补到这张已有的卡上(sid/rid,见 qaStreamApplyOne 的 ask 分支)。 */
|
|
665
|
-
bindAsk(o2){
|
|
666
|
-
const s2 = (o2 && o2.sid) || "";
|
|
667
|
-
if(s2) card.dataset.sid = String(s2);
|
|
668
|
-
if(o2 && o2.rid != null && o2.rid !== "") card.dataset.rid = String(o2.rid);
|
|
669
|
-
},
|
|
670
|
-
get state(){ return settled ? "ok" : "running"; },
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
/* ① 子代理委派卡(.qa-tool.qa-agent):工具卡变体 + 状态胶囊 + 嵌套活动区。
|
|
674
|
-
生命周期由 ② 的 system 事件驱动(start/progress/notify 只改胶囊);
|
|
675
|
-
最终结果仍按 tool_use id 配对写进「输出」(done)—— 没等到 task_notification
|
|
676
|
-
就收账时,胶囊在 done 里一并落定,不会留下常驻转圈。 */
|
|
677
|
-
function agentCall(name, argsPreview, argsFull, promptText){
|
|
678
|
-
// 委派卡的摘要本来就是 description(见调用处的 desc 取值)→ 按人话渲染
|
|
679
|
-
const t = mkCard(name, argsPreview, argsFull, true);
|
|
680
|
-
const card = t.card;
|
|
681
|
-
card.classList.add("qa-agent");
|
|
682
|
-
card.querySelector(".qa-tool-ico").innerHTML = QA_ICONS.agent;
|
|
683
|
-
const chip = document.createElement("span");
|
|
684
|
-
chip.className = "qa-agent-chip";
|
|
685
|
-
chip.textContent = "等待启动";
|
|
686
|
-
card.querySelector(".qa-tool-meta").prepend(chip);
|
|
687
|
-
const bdEl = card.querySelector(".qa-tool-bd");
|
|
688
|
-
if(promptText != null && String(promptText).length){
|
|
689
|
-
const sec = document.createElement("div");
|
|
690
|
-
sec.className = "qa-tool-sec";
|
|
691
|
-
/* 段头与「输入 / 输出」同一形制(label + 复制按钮):任务词是这条委派里最该被复制走的
|
|
692
|
-
东西(用户常要拿它去别处复用),而它原来连复制入口都没有。 */
|
|
693
|
-
sec.innerHTML = `<div class="qa-sec-hd"><span class="k">任务词</span>${QA_COPY_BTN}</div><pre></pre>`;
|
|
694
|
-
sec.querySelector("pre").textContent = String(promptText);
|
|
695
|
-
bdEl.insertBefore(sec, bdEl.firstChild); // 任务词置顶(比原始参数 JSON 更可读)
|
|
696
|
-
}
|
|
697
|
-
const sub = document.createElement("div");
|
|
698
|
-
sub.className = "qa-sub";
|
|
699
|
-
sub.innerHTML = `<div class="qa-sub-h">子代理活动</div>`;
|
|
700
|
-
bdEl.appendChild(sub);
|
|
701
|
-
bd.insertBefore(card, body);
|
|
702
|
-
qaScrollIntoView();
|
|
703
|
-
const api = {
|
|
704
|
-
done(resultText, ok = true){
|
|
705
|
-
t.done(resultText, ok);
|
|
706
|
-
if(chip.classList.contains("run")){ // 通知缺失时兜底落定(完成 → jade 绿)
|
|
707
|
-
chip.classList.remove("run");
|
|
708
|
-
if(ok) chip.classList.add("ok");
|
|
709
|
-
chip.textContent = ok ? "完成" : "未完成";
|
|
710
|
-
}
|
|
711
|
-
},
|
|
712
|
-
get state(){ return t.state; },
|
|
713
|
-
start(){ chip.textContent = "运行中"; chip.classList.add("run"); },
|
|
714
|
-
progress(label){ if(chip.classList.contains("run")) chip.textContent = String(label || "进行中"); },
|
|
715
|
-
notify(label, ok = true){
|
|
716
|
-
chip.classList.remove("run");
|
|
717
|
-
if(ok) chip.classList.add("ok");
|
|
718
|
-
chip.textContent = String(label || (ok ? "完成" : "已结束"));
|
|
719
|
-
},
|
|
720
|
-
subTool(nm, pv, full, isDesc){ // ③ 子代理内部工具 → 嵌套卡(样式更紧凑)
|
|
721
|
-
const st = mkCard(nm, pv, full, isDesc);
|
|
722
|
-
st.card.classList.add("qa-tool-sub");
|
|
723
|
-
sub.appendChild(st.card);
|
|
724
|
-
qaScrollIntoView();
|
|
725
|
-
return st;
|
|
726
|
-
},
|
|
727
|
-
subText(txt){ // ③ 子代理正文 → 嵌套区文本行
|
|
728
|
-
const el = document.createElement("div");
|
|
729
|
-
el.className = "qa-sub-t";
|
|
730
|
-
el.textContent = String(txt || "");
|
|
731
|
-
sub.appendChild(el);
|
|
732
|
-
qaScrollIntoView();
|
|
733
|
-
},
|
|
734
|
-
subNote(txt){ // ③ 子代理思考/回执 → 嵌套区注脚
|
|
735
|
-
const el = document.createElement("div");
|
|
736
|
-
el.className = "qa-sub-n";
|
|
737
|
-
el.textContent = String(txt || "");
|
|
738
|
-
sub.appendChild(el);
|
|
739
|
-
qaScrollIntoView();
|
|
740
|
-
},
|
|
741
|
-
};
|
|
742
|
-
/* 委派卡也要能被流收尾兜底落定(胶囊 + 卡状态一起)→ 覆盖 mkCard 挂的那个钩子 */
|
|
743
|
-
card.__qaDone = api.done;
|
|
744
|
-
return api;
|
|
745
|
-
}
|
|
746
|
-
/* ② 无主系统事件的降级行(父委派卡缺席时)。
|
|
747
|
-
2026-09-28 起改走**单行标记行**(29-msg-variants 的 qaMarkerNode):原来只是一行 10px
|
|
748
|
-
灰字(`.qa-note`),事件里带的 JSON 与状态**全丢**;现在同一格既能给纯文本注脚,
|
|
749
|
-
也能给「图标 + 标题 + 状态徽章 + 可展开明细」的完整形态(对齐 anywhere 的 Marker/JsonMarker)。
|
|
750
|
-
⚠️ 保留 note(text) 这个出口且签名不变:13-dispatch-stream / 24-agent-test 等旧调用方还在用,
|
|
751
|
-
它们自动升格成标记行(信息只多不少)。 */
|
|
752
|
-
function markerNode(opts){
|
|
753
|
-
const n = qaMarkerNode(opts);
|
|
754
|
-
bd.insertBefore(n, body);
|
|
755
|
-
qaScrollIntoView();
|
|
756
|
-
return n;
|
|
757
|
-
}
|
|
758
|
-
function note(text, opts){
|
|
759
|
-
return markerNode(Object.assign({ title: String(text || "") }, opts || {}));
|
|
760
|
-
}
|
|
761
|
-
/* ④ 分隔条(横切整条会话的事件,如上下文压缩边界) */
|
|
762
|
-
function sepNode(text, opts){
|
|
763
|
-
const n = qaSepNode(text, opts);
|
|
764
|
-
bd.insertBefore(n, body);
|
|
765
|
-
qaScrollIntoView();
|
|
766
|
-
return n;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
return {
|
|
770
|
-
el: div, // 这条消息的根节点(消费方要给整条消息加形态时用:
|
|
771
|
-
// 审查轮那条「审核引擎的回复」贴右 = 加个 qa-engine,见 13-dispatch-stream)
|
|
772
|
-
think,
|
|
773
|
-
body,
|
|
774
|
-
beginThinking: thinkBegin,
|
|
775
|
-
endThinking: thinkEnd,
|
|
776
|
-
beginTools: toolsBegin,
|
|
777
|
-
endTools: toolsEnd,
|
|
778
|
-
appendThinking(t){ thinkBegin(); thinkBd.textContent += t; },
|
|
779
|
-
appendText(t){
|
|
780
|
-
pending += t;
|
|
781
|
-
flushClosed();
|
|
782
|
-
renderTail();
|
|
783
|
-
},
|
|
784
|
-
setResult(text){ rerenderAll(text); },
|
|
785
|
-
toolCall,
|
|
786
|
-
agentCall,
|
|
787
|
-
askCard, // ⑤ 提问卡(AskUserQuestion → 可点选项)
|
|
788
|
-
note,
|
|
789
|
-
marker: markerNode, // ② 单行标记行(图标 + 人话标题 + 状态 + 可展开明细)
|
|
790
|
-
sep: sepNode, // ④ 分隔条(横切整条会话的事件)
|
|
791
|
-
finish(){
|
|
792
|
-
// 流式结束:把尾部 pending 也按 markdown 解析一次(行内标记如 ** / ` / []() 此时闭合完整)
|
|
793
|
-
toolsEnd(); // 收工具调用统计行
|
|
794
|
-
settleRunningTools(true); // 还在转圈的卡一律落定(配不到回执的追加卡 / 点停止那条路)
|
|
795
|
-
if(pending){
|
|
796
|
-
/* 尾部那段也走统一落笔处(理由同 flushClosed):截屏路径 → 缩略图 */
|
|
797
|
-
const tmp = document.createElement("div");
|
|
798
|
-
qaShotsMount(tmp, pending);
|
|
799
|
-
[...tmp.childNodes].forEach(node=> body.appendChild(node));
|
|
800
|
-
highlightIn(body);
|
|
801
|
-
const old = body.querySelector(".qa-tail");
|
|
802
|
-
if(old) old.remove();
|
|
803
|
-
pending = "";
|
|
804
|
-
}
|
|
805
|
-
body.classList.remove("streaming");
|
|
806
|
-
const t = body.querySelector(".qa-tail"); if(t) t.classList.remove("streaming");
|
|
807
|
-
},
|
|
808
|
-
run,
|
|
809
|
-
/** 改「生成中」胶囊上的文字。
|
|
810
|
-
* 为什么要它(2026-09-21):胶囊初值是渲染层按**链首**拼的(`qaModel`,见 04-task-list.js),
|
|
811
|
-
* 而这一轮真正用哪个模型是**主进程**按配置界面的降级链定的 —— 前一个没额度就换下一个重试
|
|
812
|
-
* (同一个 rid 会再推一条 `start`,带上实际模型)。没有这个出口,界面就会写着 A、实际跑的是 B。
|
|
813
|
-
* ⚠️ 只动胶囊文字,不重建节点(重建会打断 `.qa-run` 的转圈与裙边,见 verify-dark-chat 的断言)。 */
|
|
814
|
-
setRunLabel(t){
|
|
815
|
-
const el = run.querySelector(".qa-run-t");
|
|
816
|
-
if(el) el.textContent = String(t || "");
|
|
817
|
-
},
|
|
818
|
-
/** 改头像右边的**引擎 · 模型小标**(默认是渲染时按当前引擎挂的,见 qaRenderAnswer)。
|
|
819
|
-
* 说话人不是当前引擎时用(派发轮 = 该需求的引擎 / 模型;被测引擎 = 事件里的引擎)。
|
|
820
|
-
* 引擎传空串 = 摘掉小标(宁可没有,不写「未知」);模型空 = 只显示引擎名。 */
|
|
821
|
-
setWho(engine, model){
|
|
822
|
-
const ico = div.querySelector(".qa-ico"); if(!ico) return;
|
|
823
|
-
const lb = qaWhoLabel(engine, model);
|
|
824
|
-
let w = ico.querySelector(".qa-who");
|
|
825
|
-
if(!lb){ if(w) w.remove(); return; }
|
|
826
|
-
if(!w){ w = document.createElement("span"); w.className = "qa-who"; ico.appendChild(w); }
|
|
827
|
-
w.textContent = lb;
|
|
828
|
-
},
|
|
829
|
-
};
|
|
830
|
-
}
|
|
831
|
-
/** 把「机器内部活动」搬到**左边那条独立消息**里 —— 审核引擎与数字分身的「深度思考 / 工具调用」。
|
|
832
|
-
*
|
|
833
|
-
* 用户 2026-09-22 原话:「审核和数字分身自己的思考和工具调用要显示在左边 但是代表用户身份追问的
|
|
834
|
-
* 消息要显示在右边」。上一版只在**右边那条消息内部**把这两块靠到左边(`app.css` 的 M4),
|
|
835
|
-
* 用户看过后仍要「显示在左边」—— 所以这里升级成**真的搬到左边一条消息**:左边这半边的语义是
|
|
836
|
-
* 「机器在动」(与 bot 答复那侧同一条左边线、头像也在左),右边那半边才是「谁在说话」。
|
|
837
|
-
*
|
|
838
|
-
* ⚠️ **搬节点,不重建**:`qaRenderAnswer` 的句柄里攥着 `thinkBd` / `toolsBd` / 计数 / 计时那些元素引用,
|
|
839
|
-
* 重建会让流式期间的「思考中 · X.Xs」「工具调用 · N个」全部失联(表现为数字不再跳、正文不再进)。
|
|
840
|
-
* 搬完引用依旧有效 —— 这正是「思考 / 工具」能跟着引擎实时更新、而外层换了个容器也不受影响的原因。
|
|
841
|
-
* ⚠️ 新消息**先 hidden**:这两块在首条思考 / 首次工具调用之前是隐藏的,空着的一条消息会白占高度。
|
|
842
|
-
* 显形由 `thinkBegin` / `toolsBegin` 里那两行 `__traceWrap.hidden = false` 负责(同文件上方)。
|
|
843
|
-
* ⚠️ 头像与右边那条**同一个**(`分` / `审`):机器的活动属于**它自己**,只是「机器的事在左、人的话在右」;
|
|
844
|
-
* 两枚头像的样式都是自包含的(`.qa-av.qa-av-sim` / `.qa-av-txt.qa-av-rev` 自带底色与字色),
|
|
845
|
-
* 所以直接拷 ico 的 innerHTML 就与右边那条长得一模一样。
|
|
846
|
-
* ⚠️ **历史快照**里这两块仍在右边那条消息里(存的是当时的 HTML)—— 那条路由 `app.css` 的 M4(`:has()`)兜住:
|
|
847
|
-
* 内部块回到左边(没有独立头像,但仍在左半边的左边线上)。 */
|
|
848
|
-
function qaTraceToLeft(h, engine, model){
|
|
849
|
-
const el = h && h.el;
|
|
850
|
-
if(!el || !el.parentNode) return null;
|
|
851
|
-
const parts = ["qa-think", "qa-tools"].map((c) => el.querySelector("." + c)).filter(Boolean);
|
|
852
|
-
if(!parts.length) return null;
|
|
853
|
-
const wrap = document.createElement("div");
|
|
854
|
-
wrap.className = "qa-msg qa-trace";
|
|
855
|
-
wrap.hidden = true; // 两块都还藏着 → 整条不占位
|
|
856
|
-
wrap.innerHTML = `<div class="qa-ico"></div><div class="qa-bd"></div>`;
|
|
857
|
-
const tico = wrap.querySelector(".qa-ico");
|
|
858
|
-
const ico = el.querySelector(".qa-ico");
|
|
859
|
-
if(ico) tico.innerHTML = ico.innerHTML;
|
|
860
|
-
/* 头像右边挂**机器侧自己的引擎 · 模型**:先摘掉从右边那条拷来的旧 `.qa-who`
|
|
861
|
-
(右边那条若是左侧形状会自带一枚,那是说话人的,不是这台机器的),再按 engine/model 重挂。 */
|
|
862
|
-
const oldWho = tico.querySelector(".qa-who"); if(oldWho) oldWho.remove();
|
|
863
|
-
tico.insertAdjacentHTML("beforeend", qaWhoHtml(engine, model));
|
|
864
|
-
const bd = wrap.querySelector(".qa-bd");
|
|
865
|
-
parts.forEach((p) => bd.appendChild(p)); // 搬节点(引用不断,见上)
|
|
866
|
-
el.parentNode.insertBefore(wrap, el); // 排在右边那条**之前** = 机器在左、话在右
|
|
867
|
-
el.__traceWrap = wrap; // 首次显形时由 thinkBegin / toolsBegin 摘 hidden
|
|
868
|
-
return wrap;
|
|
869
|
-
}
|
|
870
|
-
function qaRenderDone(ok, label, host){
|
|
871
|
-
// host = 这一轮的落笔处(见 qaRunHost):后台跑的收尾行要落进它自己的画布,
|
|
872
|
-
// 不能写死 #qa-stream —— 否则「切走那条跑完了」会在**当前显示的**会话里凭空多出一行收尾。
|
|
873
|
-
const s = host || $("#qa-stream"); if(!s) return;
|
|
874
|
-
const d = document.createElement("div");
|
|
875
|
-
d.className = "qa-done" + (ok ? "" : " err") + " qa-pin";
|
|
876
|
-
d.innerHTML = `<span class="d"></span><span></span>`;
|
|
877
|
-
d.querySelector("span:last-child").textContent = label;
|
|
878
|
-
s.appendChild(d);
|
|
879
|
-
qaScrollIntoView();
|
|
880
|
-
}
|