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
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
* onOpen(runID) / onEvent(coreEvent) / onEnd(exitCode)
|
|
13
13
|
* control({id, op, text?}) -> Promise (op: interrupt | stop | answer)
|
|
14
14
|
* readSession(id, {after?}) -> Promise<{events, lastSeq?, snapshotRequired?}>
|
|
15
|
+
* listAsks() -> Promise<[{id, engine, started, session?, run?}]> (可选)
|
|
16
|
+
* 打开还在跑的会话时用它续上实时流(见 attachRunningAsk)
|
|
17
|
+
* upload(file) -> Promise<{path, name, size, mime, url?}> (可选)
|
|
18
|
+
* mediaUrl(path) -> string (可选)附件回读地址;有它历史气泡才画得出缩略图
|
|
15
19
|
* 掌天瓶走 IPC → CLI;别的对接方可能走 HTTP —— 组件对此一无所知。
|
|
16
20
|
* 现成的 HTTP 实现见同目录 transport-http.js。
|
|
17
21
|
*
|
|
@@ -23,8 +27,12 @@
|
|
|
23
27
|
* engine, model, permission: 可选初始值(空 = 不传,由 core 用自己的默认)
|
|
24
28
|
* engines: 可选。给了就用这份清单,不给则调 transport.listEngines()
|
|
25
29
|
* titleEl: 可选。宿主显示会话标题的元素(组件会写「新会话 · <引擎>」这类文案)
|
|
30
|
+
* showPermission: 可选。false 则不显示输入框下的「授权」选择器(权限仍可用 openPermissionPicker 打开)
|
|
26
31
|
* onEvent: 可选。每个 core 事件透传一份给宿主(只读)
|
|
27
32
|
* onBusy: 可选。一轮开始/结束时回调 (busy:boolean)
|
|
33
|
+
* onPick: 可选。用户在选择器里**落定**引擎/模型时回调 (engine, model)。
|
|
34
|
+
* 宿主可据此把它记成下次新建任务的默认值(移动端「我的 → 默认引擎/模型」);
|
|
35
|
+
* 只点左列引擎浏览模型不回调。引擎清单探测到记住的引擎已不可用而回退时也回调。
|
|
28
36
|
* });
|
|
29
37
|
* conv.send('…'); conv.interrupt(); conv.stop(); conv.answer('…');
|
|
30
38
|
* conv.setSession(sid) / conv.newSession() / conv.refreshEngines() / conv.destroy();
|
|
@@ -40,17 +48,34 @@
|
|
|
40
48
|
|
|
41
49
|
/* 组件自带的图标集:宿主**不必**自己准备这些 symbol。
|
|
42
50
|
前 7 个与 client-ui-mobile 里的定义逐字相同(保持两端图标一致),
|
|
43
|
-
i-cpu / i-set
|
|
51
|
+
选择器(i-cpu / i-set)、标记行(i-terminal)、工具卡归类(i-filepen / i-bot /
|
|
52
|
+
i-hammer / i-search / i-mcp / i-listcheck)都是本组件要用的,按其同一套线宽补画。
|
|
53
|
+
⚠️ i-terminal / i-filepen / i-bot / i-hammer 曾经**只被引用、从未定义**
|
|
54
|
+
(toolIconId 一直在返回这四个 id)→ <use> 指不到东西,工具卡的图标一直是空白。
|
|
55
|
+
改这块时请连同本表一起看:id 打错同样是静默失败。 */
|
|
44
56
|
var SPRITE =
|
|
45
57
|
'<symbol id="i-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6.5 9.5 5.5 5.5 5.5-5.5"/></symbol>' +
|
|
46
58
|
'<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>' +
|
|
47
|
-
'<symbol id="i-
|
|
59
|
+
'<symbol id="i-retry" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12a8 8 0 1 1-2.34-5.66"/><path d="M20 4v4h-4"/></symbol>' +
|
|
48
60
|
'<symbol id="i-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.9 4.3a.6.6 0 0 1 1.1 0l1.3 3.7a.6.6 0 0 0 .4.4l3.7 1.3a.6.6 0 0 1 0 1.1l-3.7 1.3a.6.6 0 0 0-.4.4l-1.3 3.7a.6.6 0 0 1-1.1 0l-1.3-3.7a.6.6 0 0 0-.4-.4L4.5 10.8a.6.6 0 0 1 0-1.1l3.7-1.3a.6.6 0 0 0 .4-.4z"/><path d="M18 5.5v4M20 7.5h-4M17 16v3M18.5 17.5h-3"/></symbol>' +
|
|
49
61
|
'<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16.5h.01"/></symbol>' +
|
|
50
62
|
'<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>' +
|
|
63
|
+
'<symbol id="i-terminal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4.5" width="18" height="15" rx="2.5"/><path d="M7.6 10l2.2 2.2-2.2 2.2M12.8 14.6h3.6"/></symbol>' +
|
|
64
|
+
'<symbol id="i-filepen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M13.4 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h4.2"/><path d="M13.4 3.5 18.5 8.6v3.6"/><path d="M20 12.4a1.5 1.5 0 0 0-2.1 0l-4.1 4.1-.7 3.1 3.1-.7 3.8-3.8a1.5 1.5 0 0 0 0-2.7z"/></symbol>' +
|
|
65
|
+
'<symbol id="i-bot" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="11" rx="3"/><path d="M12 4.5V8"/><path d="M9.2 12.6h.01M14.8 12.6h.01"/><path d="M10 16.2h4"/></symbol>' +
|
|
66
|
+
'<symbol id="i-hammer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14.2 3.6 20.4 9.8l-2.3 2.3-6.2-6.2z"/><path d="M11.9 8.3 4.6 15.6a1.7 1.7 0 0 0 0 2.4l1.4 1.4a1.7 1.7 0 0 0 2.4 0l7.3-7.3"/></symbol>' +
|
|
67
|
+
'<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="10.8" cy="10.8" r="6.3"/><path d="M15.5 15.5 20.5 20.5"/></symbol>' +
|
|
68
|
+
'<symbol id="i-mcp" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3.5v3.8M15 3.5v3.8"/><path d="M6.4 7.3h11.2v3.2a5.6 5.6 0 0 1-11.2 0z"/><path d="M12 16.1v4.4"/></symbol>' +
|
|
69
|
+
'<symbol id="i-listcheck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4.4 7.4 6 9l2.6-2.6M4.4 16.4 6 18l2.6-2.6"/><path d="M11.4 8h8.2M11.4 17h8.2"/></symbol>' +
|
|
51
70
|
'<symbol id="i-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19.5V5M5.8 11.2 12 5l6.2 6.2"/></symbol>' +
|
|
52
71
|
'<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><rect x="7" y="7" width="10" height="10" rx="2"/><path d="M10 4v3M14 4v3M10 17v3M14 17v3M4 10h3M4 14h3M17 10h3M17 14h3"/></symbol>' +
|
|
53
|
-
'<symbol id="i-set" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18 6l-1.6 1.6M7.6 16.4 6 18M18 18l-1.6-1.6M7.6 7.6 6 6"/></symbol>'
|
|
72
|
+
'<symbol id="i-set" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18 6l-1.6 1.6M7.6 16.4 6 18M18 18l-1.6-1.6M7.6 7.6 6 6"/></symbol>' +
|
|
73
|
+
'<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5.5v13M5.5 12h13"/></symbol>' +
|
|
74
|
+
'<symbol id="i-cam" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9a2 2 0 0 1 2-2h1.3l.9-1.5h5.6l.9 1.5H18a2 2 0 0 1 2 2v7.5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><circle cx="12" cy="12.3" r="3.1"/></symbol>' +
|
|
75
|
+
'<symbol id="i-img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="14" rx="2.5"/><circle cx="9" cy="10" r="1.6"/><path d="M4.2 16.6l4.3-3.9 3.4 3 2.5-2.2 5.4 4.4"/></symbol>' +
|
|
76
|
+
'<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.6 14.4l4.8-4.8"/><path d="M8.2 11.6l-1.7 1.7a3.3 3.3 0 0 0 4.7 4.7l1.7-1.7"/><path d="M15.8 12.4l1.7-1.7a3.3 3.3 0 0 0-4.7-4.7l-1.7 1.7"/></symbol>' +
|
|
77
|
+
'<symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></symbol>' +
|
|
78
|
+
'<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8.5" r="3.6"/><path d="M5 20a7 7 0 0 1 14 0"/></symbol>';
|
|
54
79
|
|
|
55
80
|
var spriteDone = false;
|
|
56
81
|
function ensureSprite() {
|
|
@@ -64,6 +89,100 @@
|
|
|
64
89
|
document.body.appendChild(s);
|
|
65
90
|
}
|
|
66
91
|
|
|
92
|
+
/* ══════════ 掌天瓶(绿液 loading 的图形本体)══════════
|
|
93
|
+
2026-10-20 用户:「要更有创意 使用掌天瓶填充绿液作为加载效果 app 启动动画也要有绿液」。
|
|
94
|
+
瓶 = 这个产品的名字(掌天瓶),绿液 = 灌进去的「灵液」,涨满 = 加载完成。
|
|
95
|
+
三处用同一份图形:读历史的居中 loading、App 启动闪屏、以后别的等待态。
|
|
96
|
+
⚠️ 只在这里画一次瓶身,别在宿主页里另抄一份 —— 抄了就会像历史上那三份对话实现一样漂移。
|
|
97
|
+
实现要点:
|
|
98
|
+
· 瓶型取**瓷瓶**(细颈 · 丰腹 · 小圈足),不是烧瓶:这是「掌天瓶」这个名字的形,
|
|
99
|
+
圆烧瓶那种造型一眼读成化学实验,创意就落到了错的地方。
|
|
100
|
+
· 用 SVG 而不是 div/CSS:液面要做**波浪**,CSS 做不了曲线;viewBox 固定 96×120,
|
|
101
|
+
调用方给 width/height 就能任意缩放,瓶身不会变形。
|
|
102
|
+
· 绿液用 `<clipPath>` 剪在**瓶腔以内** —— 液面涨到瓶颈时自然收窄,
|
|
103
|
+
这是「往瓶里灌」而不是「给瓶子刷绿漆」的分界。
|
|
104
|
+
· 所有动效只动 transform / opacity:回放大日志会占死主线程(见 histLoading 那段注释)。 */
|
|
105
|
+
var BOTTLE_VB = { w: 96, h: 120 };
|
|
106
|
+
/* 瓶腔:梅瓶形 —— 长颈 · 广肩 · 最宽在上腹(y≈68)· 下腹圆润回收 · 平底圈足(x31..65)。
|
|
107
|
+
液面靠这个路径裁剪。
|
|
108
|
+
⚠️ 为什么最宽点必须在**上腹**而不是正中:最宽点一半高度、底部再收成一个半圆,那是
|
|
109
|
+
圆烧瓶(一眼读成化学实验)。最宽上移 + 底部收成平足,才读得出「坛/瓶」。
|
|
110
|
+
⚠️ 下腹的弧度要**缓**:收得太急会变成沙漏(试过 x68.5@94.5 → 61.5@102.5,就是那个效果)。
|
|
111
|
+
关键尺寸(改形时一起对):瓶口 x43..53 @y11 · 最宽 x13.5..82.5 @y68 · 足 x31..65 @y106。 */
|
|
112
|
+
var BOTTLE_CAVITY = 'M48 11C50.8 11 53 12.6 53 15.4V22'
|
|
113
|
+
+ 'C53 34 53 40 53 45'
|
|
114
|
+
+ 'C59 47 72 50 78 56C81.6 60 82.5 64 82.5 68'
|
|
115
|
+
+ 'C82.5 78 79 88 72 95C68 98.8 65 101 65 103.6'
|
|
116
|
+
+ 'C65 105.4 63.8 106 61.8 106H34.2C32.2 106 31 105.4 31 103.6'
|
|
117
|
+
+ 'C31 101 28 98.8 24 95C17 88 13.5 78 13.5 68'
|
|
118
|
+
+ 'C13.5 64 14.4 60 18 56C24 50 37 47 43 45'
|
|
119
|
+
+ 'C43 40 43 34 43 22V15.4C43 12.6 45.2 11 48 11Z';
|
|
120
|
+
/** 造一枚掌天瓶。`opts.fill` = 液面停在哪(0 = 空瓶 / 1 = 满),`opts.anim` = 'hist'(涨落循环)| 'rise'(一次灌满)。
|
|
121
|
+
* @returns {SVGElement} 直接 appendChild 用。 */
|
|
122
|
+
function bottleSVG(opts) {
|
|
123
|
+
opts = opts || {};
|
|
124
|
+
var uid = 'btl' + (bottleSeq++);
|
|
125
|
+
var f = Math.max(0, Math.min(1, opts.fill == null ? 0.55 : opts.fill));
|
|
126
|
+
/* 液面顶边:在「y45(满到颈根下沿)→ y100(贴瓶底)」之间按 fill 移动。
|
|
127
|
+
上限取 45 而不是颈口 15:满瓶也要在颈里留一截空气,否则读成「塞住了」;
|
|
128
|
+
而下腹最宽点在上腹(y69),液面停在 45 时正好盖住整个腹部 —— 视觉上「装了大半瓶」。 */
|
|
129
|
+
var topY = 100 - 55 * f;
|
|
130
|
+
var svg = document.createElementNS(SVG_NS, 'svg');
|
|
131
|
+
svg.setAttribute('viewBox', '0 0 ' + BOTTLE_VB.w + ' ' + BOTTLE_VB.h);
|
|
132
|
+
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
133
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
134
|
+
svg.setAttribute('class', 'qbottle' + (opts.cls ? ' ' + opts.cls : '')
|
|
135
|
+
+ (opts.anim ? ' qbottle--' + opts.anim : ''));
|
|
136
|
+
/* 波浪起伏幅度:液面越高、可见瓶腔越窄,浪要收得越小,否则会「浪出瓶外」 */
|
|
137
|
+
var amp = 3.2 + 3.4 * (1 - f);
|
|
138
|
+
svg.innerHTML =
|
|
139
|
+
'<defs>'
|
|
140
|
+
+ '<clipPath id="' + uid + 'c"><path d="' + BOTTLE_CAVITY + '"/></clipPath>'
|
|
141
|
+
/* 绿液:上亮下深的竖向渐变,读起来像「液面反光 + 深处沉色」 */
|
|
142
|
+
+ '<linearGradient id="' + uid + 'g" x1="0" y1="0" x2="0" y2="1">'
|
|
143
|
+
+ '<stop offset="0" stop-color="var(--q-bottle-hi, #7CF7A8)"/>'
|
|
144
|
+
+ '<stop offset=".55" stop-color="var(--q-bottle, #34D27B)"/>'
|
|
145
|
+
+ '<stop offset="1" stop-color="var(--q-bottle-lo, #128A4C)"/>'
|
|
146
|
+
+ '</linearGradient>'
|
|
147
|
+
/* 液面高光:贴着液面那条亮边(越往下越透,读成「液面反光」而不是第二层液) */
|
|
148
|
+
+ '<linearGradient id="' + uid + 's" x1="0" y1="0" x2="0" y2="1">'
|
|
149
|
+
+ '<stop offset="0" stop-color="#EAFFF2" stop-opacity=".9"/>'
|
|
150
|
+
+ '<stop offset="1" stop-color="#EAFFF2" stop-opacity="0"/>'
|
|
151
|
+
+ '</linearGradient>'
|
|
152
|
+
+ '</defs>'
|
|
153
|
+
/* ── 瓶身:瓷质剪影(描边 + 极淡内底,别填实 —— 要透出后面的页面底色)── */
|
|
154
|
+
+ '<path d="' + BOTTLE_CAVITY + '" fill="var(--q-bottle-shell, rgba(255,255,255,.03))"'
|
|
155
|
+
+ ' stroke="var(--q-bottle-line, currentColor)" stroke-width="2.2" stroke-linejoin="round"/>'
|
|
156
|
+
/* ── 绿液:整块剪在瓶腔里,靠 translateY 涨上来 ── */
|
|
157
|
+
+ '<g clip-path="url(#' + uid + 'c)">'
|
|
158
|
+
+ '<g class="qbottle__liquid">'
|
|
159
|
+
/* 波峰:横跨 5 个周期(每周期 48),横移一个周期就是无缝循环;
|
|
160
|
+
左右各多画一段(-96 / 到 240)是为了横移时不露边。 */
|
|
161
|
+
+ '<path class="qbottle__wave" fill="url(#' + uid + 'g)" d="M-96 ' + topY + ' '
|
|
162
|
+
+ 'q24 -' + amp + ' 48 0 t48 0 t48 0 t48 0 t48 0 t48 0 t48 0 V124 H-96 Z"/>'
|
|
163
|
+
+ '<path fill="url(#' + uid + 's)" d="M-96 ' + topY + ' q24 -' + amp + ' 48 0'
|
|
164
|
+
+ ' t48 0 t48 0 t48 0 t48 0 t48 0 t48 0 V' + (topY + 7) + ' H-96 Z" opacity=".45"/>'
|
|
165
|
+
/* 气泡固定落在**下腹深处**(y≈94),升高 16px 也仍在液面之下(见 CSS 的 qbBubble):
|
|
166
|
+
fill .55 时液面在 y≈66,涨落 ±8 也只到 y≈74,气泡最高到 y≈78 —— 不会「浮在空气里」。 */
|
|
167
|
+
+ '<circle class="qbottle__bubble" cx="42" cy="96" r="2.4"/>'
|
|
168
|
+
+ '<circle class="qbottle__bubble qbottle__bubble--b" cx="55" cy="99" r="1.8"/>'
|
|
169
|
+
+ '<circle class="qbottle__bubble qbottle__bubble--c" cx="47" cy="101" r="1.4"/>'
|
|
170
|
+
+ '</g>'
|
|
171
|
+
+ '</g>'
|
|
172
|
+
/* ── 瓶塞:瓶颈上一枚圆角木塞(宽 16 > 瓶颈 10,两侧各出 3px,读成「塞住瓶口」)── */
|
|
173
|
+
+ '<rect x="40" y="2.6" width="16" height="9.4" rx="2.6"'
|
|
174
|
+
+ ' fill="var(--q-bottle-cork, currentColor)" opacity=".42"/>'
|
|
175
|
+
/* 木塞顶面一道细亮边,免得它读成一块实心方块 */
|
|
176
|
+
+ '<rect x="40" y="2.6" width="16" height="3.2" rx="1.6"'
|
|
177
|
+
+ ' fill="var(--q-bottle-hi, #7CF7A8)" opacity=".22"/>'
|
|
178
|
+
/* ── 反光:左肩一道竖向亮线 + 上腹一点高光,让瓶「有弧度」── */
|
|
179
|
+
+ '<path d="M23 62c-3.4 4.4-4.6 9.4-4 14.6" stroke="var(--q-bottle-hi, #7CF7A8)" stroke-width="3"'
|
|
180
|
+
+ ' stroke-linecap="round" fill="none" opacity=".3"/>'
|
|
181
|
+
+ '<ellipse cx="31" cy="78" rx="4.2" ry="6.4" fill="var(--q-bottle-hi, #7CF7A8)" opacity=".14"/>';
|
|
182
|
+
return svg;
|
|
183
|
+
}
|
|
184
|
+
var bottleSeq = 0;
|
|
185
|
+
|
|
67
186
|
function create(opts) {
|
|
68
187
|
opts = opts || {};
|
|
69
188
|
var root = opts.el;
|
|
@@ -76,6 +195,18 @@
|
|
|
76
195
|
if (!transport || typeof transport.ask !== 'function') {
|
|
77
196
|
throw new Error('MagicConversation.create: 缺 transport(需实现 listEngines / ask / control / readSession)');
|
|
78
197
|
}
|
|
198
|
+
/* 对话内核(`shared/conv/`)必须**先**加载:画面由它画(本文件不再各留一套)。
|
|
199
|
+
缺了就在**开场**当场报错 —— 拖到运行中才 ReferenceError 的话,
|
|
200
|
+
表现是「某一笔突然画不出来」,而那时已经不知道该怪谁了。 */
|
|
201
|
+
if (typeof qaRenderMine !== 'function' || typeof qaRenderAnswer !== 'function'
|
|
202
|
+
|| typeof qaStreamApply !== 'function' || typeof qaRenderDone !== 'function'
|
|
203
|
+
|| typeof bindQaStreamDelegation !== 'function'
|
|
204
|
+
|| typeof mdToHtmlOnly !== 'function' || typeof highlightIn !== 'function') {
|
|
205
|
+
throw new Error('MagicConversation.create: 缺对话内核 —— 宿主页要按 shared/conv/manifest.json 的'
|
|
206
|
+
+ '顺序引全 12 片(画面的唯一实现:qaRenderMine / qaRenderAnswer / qaStreamApply / '
|
|
207
|
+
+ 'qaRenderDone / bindQaStreamDelegation 与 markdown 渲染栈都在里面),'
|
|
208
|
+
+ '并在它之前引 marked / DOMPurify(shared/vendor/),再引 shared/host-glue.js');
|
|
209
|
+
}
|
|
79
210
|
ensureSprite();
|
|
80
211
|
|
|
81
212
|
/* ── 组件自有的 DOM 引用 ──
|
|
@@ -106,6 +237,7 @@
|
|
|
106
237
|
s.appendChild(u);
|
|
107
238
|
return s;
|
|
108
239
|
}
|
|
240
|
+
|
|
109
241
|
function box(cls, iconId, size) {
|
|
110
242
|
var d = el('span', cls);
|
|
111
243
|
d.appendChild(svgIcon(iconId, size));
|
|
@@ -114,436 +246,172 @@
|
|
|
114
246
|
|
|
115
247
|
var state = {
|
|
116
248
|
engines: opts.engines || [], engine: opts.engine || '', model: opts.model || '',
|
|
117
|
-
permission: opts.permission || '', session: opts.session || '', busy: false, askId: null,
|
|
118
|
-
renderedSeq: {},
|
|
119
|
-
|
|
249
|
+
permission: opts.permission || '', workspace: opts.workspace || '', attachments: [], session: opts.session || '', busy: false, askId: null,
|
|
250
|
+
renderedSeq: {}, agentRaw: '', replay: false,
|
|
251
|
+
/* 本轮已收到的最大 seq(**流 seq,每轮从 1 重来**,见 render 的 seq 去重注释)。
|
|
252
|
+
断线后拿它当游标调 /desk/ask/{id}/events?after= 增量续传。 */
|
|
253
|
+
lastSeq: 0,
|
|
254
|
+
/* 断线续传轮询的取消钩子(换会话 / 开新轮时调用,避免旧轮的事件落进新画面) */
|
|
255
|
+
resumeStop: null,
|
|
256
|
+
working: null, autoFollow: true, lastPrompt: '', lastAttPaths: [],
|
|
257
|
+
/* 这一轮的渲染句柄(内核给的 h + 它的工具 / 委派登记表 + 起点),见 qaEnsureAnswer */
|
|
258
|
+
qa: null,
|
|
259
|
+
/* 本轮**已收尾**(turn_end / error / end 已落过收尾行)。
|
|
260
|
+
收尾后仍会有迟到的事件:core 的 result 信封在进程退出前才补发,虽然 turn_end 已经
|
|
261
|
+
把权威正文画过了,这条 result 还会再走一遍 kernelApply。若放它进 qaEnsureAnswer,
|
|
262
|
+
就会凭空长出第二条助手气泡 + 一枚永远停在「生成中」的运行胶囊
|
|
263
|
+
(2026-10-19 真实报障:回复被画两遍、底部一直生成中)。 */
|
|
264
|
+
qaClosed: false,
|
|
265
|
+
/* 历史分页游标:`{before, more, loading}`,null = 没加载 / 旧服务端(整份回放)。
|
|
266
|
+
`before` 是「从文件尾跳过多少条事件」(不是 seq),见 loadHistory。 */
|
|
267
|
+
hist: null,
|
|
268
|
+
/* 回放时的「真实时间轴」:日志每条事件自带 `at`(ISO 墙钟)。
|
|
269
|
+
回放发生在几毫秒内,拿墙上时钟算时长会写成「任务耗时 0.0s」——
|
|
270
|
+
这里记本轮首条 / 末条事件的 `at`,收尾时用它们的差(拿不到就不写时间)。 */
|
|
271
|
+
replayT0: 0, replayLastAt: 0,
|
|
272
|
+
/* 打开一条**还在跑**的会话时,这一轮的真实起点(日志里最后一条 user 的 `at`)。
|
|
273
|
+
续传轮没有本地的「发送时刻」,收尾耗时只能从这个起点算起 ——
|
|
274
|
+
否则会写成「总耗时 3s」(打开页面到结束的时长),与真实耗时差一个数量级。
|
|
275
|
+
0 = 本轮不是续传来的,按 q.t0 算。 */
|
|
276
|
+
resumeT0: 0,
|
|
277
|
+
/* 引擎停在一问上等答案(见 MagicHost.send:这种时候作答要走控制通道,不能当新的一轮发) */
|
|
278
|
+
awaitingAnswer: false,
|
|
279
|
+
/* 内核「重试」走 MagicHost.send(…, {echo:false}):那条气泡已经在画布上了,别再画一遍 */
|
|
280
|
+
noEcho: false
|
|
120
281
|
};
|
|
121
282
|
|
|
122
|
-
/* ══════════ markdown
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (t.indexOf('/') < 0) return null;
|
|
134
|
-
if (!/\.[a-zA-Z0-9]+(?::\d+(?::\d+)?)?$/.test(t)) return null;
|
|
135
|
-
return t.replace(/:\d+(?::\d+)?$/, '');
|
|
136
|
-
}
|
|
137
|
-
var COPY_TOKEN = '\u0000';
|
|
138
|
-
var HARD_BREAK = '\u0001';
|
|
139
|
-
var copyBin = [];
|
|
140
|
-
function copyAttr(text) {
|
|
141
|
-
copyBin.push(String(text == null ? '' : text));
|
|
142
|
-
return ' data-copy-idx="' + (copyBin.length - 1) + '"';
|
|
143
|
-
}
|
|
144
|
-
var COPY_BTN_HTML = '<button type="button" class="copybtn copybtn--sm" aria-label="复制"' + '\u0002' +
|
|
145
|
-
'><svg width="15" height="15"><use href="#i-copy"/></svg></button>';
|
|
146
|
-
|
|
147
|
-
function inlineMd(src) {
|
|
148
|
-
var tokens = [];
|
|
149
|
-
var s = String(src == null ? '' : src);
|
|
150
|
-
// 硬换行(行尾两空格 或 反斜杠)
|
|
151
|
-
s = s.replace(/\\\n/g, HARD_BREAK).replace(/ {2,}\n/g, HARD_BREAK);
|
|
152
|
-
// 行内码先摘出来,避免被转义与强调规则动到
|
|
153
|
-
s = s.replace(/`([^`]+)`/g, function (_, code) {
|
|
154
|
-
var isFile = fileRef(code);
|
|
155
|
-
tokens.push('<code class="md-code' + (isFile ? ' md-code--file' : '') +
|
|
156
|
-
(code.length > 30 || /\s/.test(code) ? ' md-code--wrap' : '') + '">' + esc(code) + '</code>');
|
|
157
|
-
return COPY_TOKEN + (tokens.length - 1) + COPY_TOKEN;
|
|
158
|
-
});
|
|
159
|
-
// markdown 链接也先摘出来
|
|
160
|
-
s = s.replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, function (_, text, href) {
|
|
161
|
-
tokens.push('<a href="' + esc(href) + '" target="_blank" rel="noreferrer noopener">' + esc(text) + '</a>');
|
|
162
|
-
return COPY_TOKEN + (tokens.length - 1) + COPY_TOKEN;
|
|
163
|
-
});
|
|
164
|
-
// 图片 → 只保留 alt 文字(:428)
|
|
165
|
-
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1');
|
|
166
|
-
s = esc(s);
|
|
167
|
-
// 尖括号自动链接
|
|
168
|
-
s = s.replace(/<((?:https?:\/\/|mailto:)[^\s&]+)>/g, function (_, url) {
|
|
169
|
-
return '<a href="' + url + '" target="_blank" rel="noreferrer noopener">' + url + '</a>';
|
|
170
|
-
});
|
|
171
|
-
// 裸 URL
|
|
172
|
-
s = s.replace(/(^|[\s((])((?:https?:\/\/)[^\s<>"'))]+)/g, function (_, pre, url) {
|
|
173
|
-
var tail = '';
|
|
174
|
-
while (/[.,;:!?]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); }
|
|
175
|
-
if (!url) return pre;
|
|
176
|
-
return pre + '<a href="' + url + '" target="_blank" rel="noreferrer noopener">' + url + '</a>' + tail;
|
|
177
|
-
});
|
|
178
|
-
s = s.replace(/\*\*([^\s*](?:[\s\S]*?[^\s*])?)\*\*/g, '<strong>$1</strong>');
|
|
179
|
-
s = s.replace(/__([^\s_](?:[\s\S]*?[^\s_])?)__/g, '<strong>$1</strong>');
|
|
180
|
-
s = s.replace(/~~([^\s~](?:[\s\S]*?[^\s~])?)~~/g, '<del>$1</del>');
|
|
181
|
-
s = s.replace(/(^|[^*\w])\*([^\s*](?:[^*]*?[^\s*])?)\*/g, '$1<em>$2</em>');
|
|
182
|
-
s = s.replace(/(^|[^_\w])_([^\s_](?:[^_]*?[^\s_])?)_/g, '$1<em>$2</em>');
|
|
183
|
-
s = s.split(HARD_BREAK).join('<br>').split('\n').join(' ');
|
|
184
|
-
s = s.replace(new RegExp(COPY_TOKEN + '(\\d+)' + COPY_TOKEN, 'g'), function (_, i) { return tokens[+i]; });
|
|
185
|
-
return s;
|
|
186
|
-
}
|
|
283
|
+
/* ══════════ markdown 渲染(**栈归内核**)══════════
|
|
284
|
+
2026-10-16 阶段三:本文件以前自带一套手写解析(inlineMd / readTable / readList /
|
|
285
|
+
codePanelHtml / mdToHtml,约 200 行)—— 只覆盖「agent 回复里实际会出现」的子集,
|
|
286
|
+
表格嵌套、块级代码、任务列表、行内 HTML、流程图都不认,而且与 PC 那份长期漂移。
|
|
287
|
+
现在渲染栈只有内核那一份:`shared/conv/10-markdown-render.js`(marked + DOMPurify +
|
|
288
|
+
highlight.js + mermaid;宿主页要先引 marked / purify 与内核分片)。
|
|
289
|
+
渲染**之后**的两件收尾仍归本组件(那才是「皮」):
|
|
290
|
+
① 代码块补一枚复制按钮(内核那层不画按钮);
|
|
291
|
+
② 调内核的 `highlightIn`:语法高亮 + 流程图出图(它自己按需加载 highlight.js,
|
|
292
|
+
地址由宿主给 `window.QA_HLJS_URL`)。
|
|
293
|
+
⚠️ 内核那份自带消毒(DOMPurify:禁 script / iframe / 内联事件),所以这里不再自己转义。 */
|
|
187
294
|
|
|
188
|
-
function splitCells(line) {
|
|
189
|
-
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { return c.trim(); });
|
|
190
|
-
}
|
|
191
|
-
function isTableDelim(line) {
|
|
192
|
-
var c = splitCells(line);
|
|
193
|
-
return c.length >= 2 && c.every(function (x) { return /^:?-{1,}:?$/.test(x); });
|
|
194
|
-
}
|
|
195
|
-
function isTableHeader(line) {
|
|
196
|
-
if (isTableDelim(line)) return false;
|
|
197
|
-
var c = splitCells(line);
|
|
198
|
-
return c.length >= 2 && c.some(function (x) { return x !== ''; });
|
|
199
|
-
}
|
|
200
|
-
function readTable(lines, i) {
|
|
201
|
-
var header = splitCells(lines[i]);
|
|
202
|
-
var aligns = splitCells(lines[i + 1]).map(function (x) {
|
|
203
|
-
if (/^:-+:$/.test(x)) return 'center';
|
|
204
|
-
if (/^-+:$/.test(x)) return 'right';
|
|
205
|
-
return 'left';
|
|
206
|
-
});
|
|
207
|
-
i += 2;
|
|
208
|
-
var body = [];
|
|
209
|
-
while (i < lines.length && lines[i].trim() && lines[i].indexOf('|') >= 0) { body.push(splitCells(lines[i])); i++; }
|
|
210
|
-
var h = '<div class="md-table-wrap"><table><thead><tr>';
|
|
211
|
-
header.forEach(function (c, k) {
|
|
212
|
-
h += '<th style="text-align:' + (aligns[k] || 'left') + '">' + inlineMd(c) + '</th>';
|
|
213
|
-
});
|
|
214
|
-
h += '</tr></thead><tbody>';
|
|
215
|
-
body.forEach(function (r) {
|
|
216
|
-
h += '<tr>';
|
|
217
|
-
for (var k = 0; k < header.length; k++) {
|
|
218
|
-
h += '<td style="text-align:' + (aligns[k] || 'left') + '">' + inlineMd(r[k] || '') + '</td>';
|
|
219
|
-
}
|
|
220
|
-
h += '</tr>';
|
|
221
|
-
});
|
|
222
|
-
return { html: h + '</tbody></table></div>', next: i };
|
|
223
|
-
}
|
|
224
|
-
function readList(lines, i) {
|
|
225
|
-
var ordered = /^\s*\d+[.)]\s+/.test(lines[i]);
|
|
226
|
-
var m = lines[i].match(/^\s*(\d+)[.)]\s+/);
|
|
227
|
-
var start = m ? parseInt(m[1], 10) : 1;
|
|
228
|
-
var items = [], idx = 0;
|
|
229
|
-
while (i < lines.length) {
|
|
230
|
-
var mm = lines[i].match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/);
|
|
231
|
-
if (!mm) break;
|
|
232
|
-
var body = mm[1], task = null;
|
|
233
|
-
var tm = body.match(/^\[([ xX])\]\s+(.*)$/);
|
|
234
|
-
if (tm) { task = tm[1].toLowerCase() === 'x'; body = tm[2]; }
|
|
235
|
-
// 续行:缩进的普通行并到上一条
|
|
236
|
-
i++;
|
|
237
|
-
while (i < lines.length && lines[i].trim() && /^\s{2,}/.test(lines[i]) &&
|
|
238
|
-
!/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[i])) {
|
|
239
|
-
body += ' ' + lines[i].trim(); i++;
|
|
240
|
-
}
|
|
241
|
-
items.push({ task: task, text: body, num: ordered ? start + idx : null });
|
|
242
|
-
idx++;
|
|
243
|
-
}
|
|
244
|
-
var tag = ordered ? 'ol' : 'ul';
|
|
245
|
-
var h = '<' + tag + '>';
|
|
246
|
-
items.forEach(function (it) {
|
|
247
|
-
// :222-227 anywhere 的 bullet 前缀就是字面量 "-"(不是圆点);任务列表走 mono 的 [ ] / [x]
|
|
248
|
-
var mk = it.task === null ? (it.num != null ? it.num + '.' : '-') : (it.task ? '[x]' : '[ ]');
|
|
249
|
-
h += '<li><span class="mk' + (it.task !== null ? ' mk--mono' : '') + '">' + esc(mk) + '</span>' +
|
|
250
|
-
'<span class="li-b">' + inlineMd(it.text) + '</span></li>';
|
|
251
|
-
});
|
|
252
|
-
return { html: h + '</' + tag + '>', next: i };
|
|
253
|
-
}
|
|
254
|
-
var BASH_LABELS = { bash: 1, sh: 1, shell: 1, zsh: 1, console: 1, terminal: 1 };
|
|
255
|
-
function codePanelHtml(label, code) {
|
|
256
|
-
var isBash = !!BASH_LABELS[String(label || '').toLowerCase()];
|
|
257
|
-
var body;
|
|
258
|
-
if (isBash) {
|
|
259
|
-
// MarkdownCodePanel(:515-544) 走 BashCommandCard:按行拆,首个 token 上 prompt 色
|
|
260
|
-
var cmds = String(code).split('\n').map(function (x) { return x.replace(/\s+$/, ''); })
|
|
261
|
-
.filter(function (x) { return x.trim() !== ''; });
|
|
262
|
-
if (!cmds.length) cmds = [code];
|
|
263
|
-
body = cmds.map(function (c) {
|
|
264
|
-
var t = c.trim(), sp = t.indexOf(' ');
|
|
265
|
-
var first = sp < 0 ? t : t.slice(0, sp);
|
|
266
|
-
var rest = sp < 0 ? '' : t.slice(sp + 1).replace(/^\s+/, '');
|
|
267
|
-
return '<div class="cmdline"><span class="cmdline__p">' + esc(first) + '</span>' +
|
|
268
|
-
(rest ? '<span class="cmdline__r">' + esc(rest) + '</span>' : '') + '</div>';
|
|
269
|
-
}).join('');
|
|
270
|
-
} else {
|
|
271
|
-
body = '<pre class="codepanel__pre">' + esc(code) + '</pre>';
|
|
272
|
-
}
|
|
273
|
-
return '<div class="codepanel"><div class="codepanel__head">' +
|
|
274
|
-
'<span class="codepanel__label">' + esc(isBash ? 'Bash' : (label || 'code')) + '</span>' +
|
|
275
|
-
COPY_BTN_HTML.split('\u0002').join(copyAttr(code)) +
|
|
276
|
-
'</div><div class="codepanel__code">' + body + '</div></div>';
|
|
277
|
-
}
|
|
278
|
-
function mdToHtml(src) {
|
|
279
|
-
var lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
|
|
280
|
-
// :883-911 normalizeMarkdownTables:表格前一行不是空行时补一个空行
|
|
281
|
-
var norm = [], inFence = false, k;
|
|
282
|
-
for (k = 0; k < lines.length; k++) {
|
|
283
|
-
if (!inFence && k + 1 < lines.length && isTableHeader(lines[k]) && isTableDelim(lines[k + 1]) &&
|
|
284
|
-
norm.length && norm[norm.length - 1].trim() !== '') norm.push('');
|
|
285
|
-
norm.push(lines[k]);
|
|
286
|
-
if (/^\s*(```|~~~)/.test(lines[k])) inFence = !inFence;
|
|
287
|
-
}
|
|
288
|
-
var n = norm.length, i = 0, out = [];
|
|
289
|
-
while (i < n) {
|
|
290
|
-
var line = norm[i];
|
|
291
|
-
if (!line.trim()) { i++; continue; }
|
|
292
|
-
var fence = line.match(/^\s*(```|~~~)\s*(.*)$/);
|
|
293
|
-
if (fence) {
|
|
294
|
-
var mark = fence[1];
|
|
295
|
-
var label = (fence[2] || '').trim().split(/\s+/)[0] || 'code';
|
|
296
|
-
var buf = []; i++;
|
|
297
|
-
while (i < n && !new RegExp('^\\s*' + mark).test(norm[i])) { buf.push(norm[i]); i++; }
|
|
298
|
-
i++;
|
|
299
|
-
out.push(codePanelHtml(label, buf.join('\n')));
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
var h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
303
|
-
if (h) { out.push('<h' + h[1].length + '>' + inlineMd(h[2].trim()) + '</h' + h[1].length + '>'); i++; continue; }
|
|
304
|
-
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
|
|
305
|
-
if (/^\s*>/.test(line)) {
|
|
306
|
-
var qb = [];
|
|
307
|
-
while (i < n && /^\s*>/.test(norm[i])) { qb.push(norm[i].replace(/^\s*>\s?/, '')); i++; }
|
|
308
|
-
out.push('<blockquote><span class="bar"></span><div class="q-b">' + mdToHtml(qb.join('\n')) + '</div></blockquote>');
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) {
|
|
312
|
-
var rl = readList(norm, i); out.push(rl.html); i = rl.next; continue;
|
|
313
|
-
}
|
|
314
|
-
if (isTableHeader(line) && i + 1 < n && isTableDelim(norm[i + 1])) {
|
|
315
|
-
var rt = readTable(norm, i); out.push(rt.html); i = rt.next; continue;
|
|
316
|
-
}
|
|
317
|
-
var pb = [];
|
|
318
|
-
while (i < n && norm[i].trim() &&
|
|
319
|
-
!/^\s*(```|~~~)/.test(norm[i]) && !/^#{1,6}\s/.test(norm[i]) &&
|
|
320
|
-
!/^\s*>/.test(norm[i]) && !/^\s*(?:[-*+]|\d+[.)])\s+/.test(norm[i]) &&
|
|
321
|
-
!/^\s*([-*_])(\s*\1){2,}\s*$/.test(norm[i]) &&
|
|
322
|
-
!(isTableHeader(norm[i]) && i + 1 < n && isTableDelim(norm[i + 1]))) {
|
|
323
|
-
pb.push(norm[i]); i++;
|
|
324
|
-
}
|
|
325
|
-
if (pb.length) out.push('<p>' + inlineMd(pb.join('\n')) + '</p>');
|
|
326
|
-
else i++;
|
|
327
|
-
}
|
|
328
|
-
return out.join('');
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/* ══════════ 对话区组件 ══════════ */
|
|
332
295
|
function thread() { return $('thread'); }
|
|
333
296
|
function scrollBottom() { var t = thread(); t.scrollTop = t.scrollHeight; }
|
|
334
297
|
function nearBottom() { var t = thread(); return t.scrollHeight - t.scrollTop - t.clientHeight < 48; }
|
|
335
298
|
function pushItem(node, force) {
|
|
336
|
-
thread()
|
|
299
|
+
var t = thread();
|
|
300
|
+
t.appendChild(node);
|
|
301
|
+
/* 运行指示器始终贴在**最新内容之后**:它读作「此刻在干什么」,
|
|
302
|
+
而不是「这一轮从这里开始」。只搬节点、不重建 —— 重建会让秒表与动画重新开始。
|
|
303
|
+
⚠️ 只在它**还在画布里**时搬(2026-10-20):它已经被并进这一轮那条消息的行内之后
|
|
304
|
+
(见 mountWorking),这里再 append 一下就等于把它从消息里拽回画布、又变成独占一行。 */
|
|
305
|
+
var w = state.working && state.working.el;
|
|
306
|
+
if (w && w !== node && w.parentNode === t) t.appendChild(w);
|
|
337
307
|
if (force || state.autoFollow) scrollBottom();
|
|
338
308
|
}
|
|
339
|
-
function tgroup() { return el('div', 'tgroup'); }
|
|
340
|
-
function doCopy(text) {
|
|
341
|
-
var s = String(text == null ? '' : text);
|
|
342
|
-
try {
|
|
343
|
-
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(s);
|
|
344
|
-
else {
|
|
345
|
-
var ta = el('textarea');
|
|
346
|
-
ta.value = s; ta.style.cssText = 'position:fixed;left:-9999px';
|
|
347
|
-
document.body.appendChild(ta); ta.select();
|
|
348
|
-
try { document.execCommand('copy'); } catch (e) {}
|
|
349
|
-
document.body.removeChild(ta);
|
|
350
|
-
}
|
|
351
|
-
} catch (e) {}
|
|
352
|
-
}
|
|
353
|
-
function copyBtn(text) {
|
|
354
|
-
var b = el('button', 'copybtn');
|
|
355
|
-
b.type = 'button';
|
|
356
|
-
b.setAttribute('aria-label', '复制');
|
|
357
|
-
b.appendChild(svgIcon('i-copy', 17));
|
|
358
|
-
b.onclick = function () { doCopy(text); };
|
|
359
|
-
return b;
|
|
360
|
-
}
|
|
361
309
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
var
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
row.appendChild(col);
|
|
374
|
-
wrap.appendChild(row);
|
|
375
|
-
pushItem(wrap, true);
|
|
376
|
-
// :1352-1356 maxLines = 8,溢出时给「展开 / 收起」
|
|
377
|
-
var w = t.clientWidth;
|
|
378
|
-
if (w > 0) {
|
|
379
|
-
var probe = el('div', 'ubub__t');
|
|
380
|
-
probe.style.cssText = 'position:absolute;left:-99999px;top:0;visibility:hidden;' +
|
|
381
|
-
'display:block;max-height:none;overflow:visible;width:' + w + 'px';
|
|
382
|
-
probe.textContent = text;
|
|
383
|
-
document.body.appendChild(probe);
|
|
384
|
-
var full = probe.scrollHeight;
|
|
385
|
-
document.body.removeChild(probe);
|
|
386
|
-
if (full > 8 * 24 + 2) {
|
|
387
|
-
var btn = el('button', 'ubub__more', '展开');
|
|
388
|
-
btn.type = 'button';
|
|
389
|
-
btn.onclick = function () { btn.textContent = bub.classList.toggle('is-open') ? '收起' : '展开'; };
|
|
390
|
-
bub.appendChild(btn);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
310
|
+
/** 耗时文案:<60s 给一位小数的秒、<60m 给「7m 16s」、再往上给「1h 2m」。
|
|
311
|
+
* ⚠️ 拿不到耗时(没起点 / 非数 / 负数)回空串 —— **不编一个 0.0s**。 */
|
|
312
|
+
function durText(ms) {
|
|
313
|
+
if (ms == null || ms === '') return '';
|
|
314
|
+
var n = Number(ms);
|
|
315
|
+
if (!isFinite(n) || n < 0) return '';
|
|
316
|
+
var sec = Math.round(n / 1000);
|
|
317
|
+
if (sec < 60) return (n / 1000).toFixed(1) + 's';
|
|
318
|
+
var m = Math.floor(sec / 60);
|
|
319
|
+
if (m < 60) return m + 'm ' + (sec % 60) + 's';
|
|
320
|
+
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
|
|
393
321
|
}
|
|
394
322
|
|
|
395
|
-
/* AgentMessageContent(:1238-1260) → AgentMarkdownText */
|
|
396
|
-
function ensureAgent() {
|
|
397
|
-
if (state.cur) return state.cur;
|
|
398
|
-
var wrap = tgroup();
|
|
399
|
-
var md = el('div', 'md');
|
|
400
|
-
wrap.appendChild(md);
|
|
401
|
-
pushItem(wrap);
|
|
402
|
-
state.cur = { wrap: wrap, md: md };
|
|
403
|
-
state.agentRaw = '';
|
|
404
|
-
return state.cur;
|
|
405
|
-
}
|
|
406
323
|
var mdTimer = 0;
|
|
407
|
-
function flushMd() {
|
|
408
|
-
mdTimer = 0;
|
|
409
|
-
if (!state.cur) return;
|
|
410
|
-
state.cur.md.innerHTML = mdToHtml(state.agentRaw);
|
|
411
|
-
if (state.autoFollow) scrollBottom();
|
|
412
|
-
}
|
|
413
|
-
function scheduleMd() {
|
|
414
|
-
if (mdTimer) return;
|
|
415
|
-
mdTimer = setTimeout(flushMd, 40); // 流式期间合并重绘,避免每个 chunk 都跑一遍 markdown
|
|
416
|
-
}
|
|
417
|
-
function finalizeAgent(withActions) {
|
|
418
|
-
if (!state.cur) return;
|
|
419
|
-
if (mdTimer) { clearTimeout(mdTimer); mdTimer = 0; }
|
|
420
|
-
flushMd();
|
|
421
|
-
if (withActions && state.agentRaw.trim()) state.cur.wrap.appendChild(replyActions(state.agentRaw));
|
|
422
|
-
state.cur = null; state.agentRaw = '';
|
|
423
|
-
}
|
|
424
324
|
|
|
425
|
-
/*
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
row.appendChild(copyBtn(text));
|
|
431
|
-
var share = el('button', 'copybtn');
|
|
432
|
-
share.type = 'button';
|
|
433
|
-
share.setAttribute('aria-label', '分享');
|
|
434
|
-
share.appendChild(svgIcon('i-share', 17));
|
|
435
|
-
share.onclick = function () {
|
|
436
|
-
var s = String(text || '');
|
|
437
|
-
if (navigator.share) { try { navigator.share({ text: s }); return; } catch (e) {} }
|
|
438
|
-
doCopy(s);
|
|
439
|
-
};
|
|
440
|
-
row.appendChild(share);
|
|
441
|
-
d.appendChild(row);
|
|
442
|
-
return d;
|
|
443
|
-
}
|
|
325
|
+
/* WorkingIndicator(:1072-1120)
|
|
326
|
+
一轮从 setWorking 到第一条输出之间可能是几十秒的空白(起进程 / 探测引擎 / 长思考)。
|
|
327
|
+
这一行负责证明「还在动」:实时秒数给没卡死的佐证,bot 头像的呼吸给在动的观感 ——
|
|
328
|
+
与 PC 的思考行 / 工具行的「秒数跳动」同一口径(:1693 / :1710)。
|
|
329
|
+
⚠️ 这里的类名与 conversation.css 的 `.working*` 成对,改名要两边一起改。
|
|
444
330
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
if (head.getAttribute('data-plain') === '1') return;
|
|
464
|
-
var open = head.getAttribute('aria-expanded') !== 'true';
|
|
465
|
-
head.setAttribute('aria-expanded', String(open));
|
|
466
|
-
body.style.display = open ? '' : 'none';
|
|
467
|
-
};
|
|
468
|
-
d.appendChild(head); d.appendChild(body);
|
|
469
|
-
pushItem(d);
|
|
470
|
-
state.rsn = { el: d, head: head, chev: chev, title: title, body: body, md: md, plain: false };
|
|
471
|
-
state.rsnRaw = '';
|
|
472
|
-
return state.rsn;
|
|
473
|
-
}
|
|
474
|
-
function plainSummary(s) {
|
|
475
|
-
return String(s || '')
|
|
476
|
-
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
477
|
-
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
478
|
-
.replace(/`([^`]+)`/g, '$1')
|
|
479
|
-
.replace(/[*_~#>]+/g, '')
|
|
480
|
-
.replace(/\s+/g, ' ')
|
|
481
|
-
.trim();
|
|
331
|
+
2026-10-20 用户:「agent 运行中这一段和下面的bot图标合并到一行 运行中的图标动画也改到bot上显示」。
|
|
332
|
+
它原本是画布里**独占一行**的兄弟节点,于是运行中永远是两行(这行 + 下面那条 bot 头像行)。
|
|
333
|
+
改法:**只并、不单画** —— 这一轮的消息一旦建出来(qaRenderAnswer,见 mountWorking),
|
|
334
|
+
把这个节点搬进那条消息里、与 bot 头像同一行;转圈那一枚交给 bot 图标自己
|
|
335
|
+
(`.qa-runlive` 的规则与本文件同侧的 conversation.css 成对 —— 它关联的是内核画的
|
|
336
|
+
`.qa-msg` / `.qa-ico`,但**类由本壳盖**,所以规则跟着本壳走,见 conv/README 的边界表)。
|
|
337
|
+
⚠️ 消息还没建出来时(起进程 / 探测引擎那段空白)**什么都不画**:
|
|
338
|
+
用户看到那行「等待处理… + 蓝圈 + 秒数」时明确表态「不需要这个效果」——
|
|
339
|
+
一条没有正文、却顶着 spinner 的假 bot 行,读起来像第二条回复。
|
|
340
|
+
那段时间的「还在跑」由输入区那行「生成中」承担(见 composer 的 `composer__gen`)。 */
|
|
341
|
+
var WORK_TICK = 0, WORK_T0 = 0;
|
|
342
|
+
/* 「运行中」并进去之后,宿主给它盖的这一枚类(挂在**那条消息**上,不是画布上)。
|
|
343
|
+
为什么由宿主盖而不是内核画:这一行是**本壳**的业务(引擎起没起来),内核那条消息
|
|
344
|
+
并不知情 —— 见 shared/conv/README.md 的边界表「事件 → 画面」与「宿主外壳」那一行。 */
|
|
345
|
+
var RUNLIVE_CLS = 'qa-runlive';
|
|
346
|
+
function workElapsed() {
|
|
347
|
+
var w = state.working;
|
|
348
|
+
if (w && w.d) w.d.textContent = WORK_T0 ? durText(Date.now() - WORK_T0) : '';
|
|
482
349
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if (
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
state.rsn = null; state.rsnRaw = '';
|
|
350
|
+
/* 把「运行中」并到这一轮消息的 bot 头像那一行。
|
|
351
|
+
幂等:只在「消息里有它、画布里没有它」时搬 —— 反复调用(每条事件都过一次)不会搬第二次。
|
|
352
|
+
⚠️ 只认 `state.qa.h.el`(**这一轮**那条消息):拿画布里最后一条 `.qa-msg` 去并会把指示器
|
|
353
|
+
贴到上一轮的收尾行上。 */
|
|
354
|
+
function mountWorking() {
|
|
355
|
+
var w = state.working; if (!w) return;
|
|
356
|
+
var msg = state.qa && state.qa.h && state.qa.h.el;
|
|
357
|
+
if (!msg || !msg.parentNode) return;
|
|
358
|
+
if (w.el.parentNode === msg) return;
|
|
359
|
+
if (w.el.parentNode && w.el.parentNode !== thread()) return; // 已经并到别的消息里去了
|
|
360
|
+
/* ⚠️ 必须插在 `.qa-ico` **之后、`.qa-bd` 之前**(2026-10-20):正文那条
|
|
361
|
+
`.qa-msg:not(.qa-mine) > .qa-bd` 是 `flex:0 0 100%` 整行,appendChild 会让
|
|
362
|
+
「运行中」落到第三行(头像一行、正文一行、它在最后一行)—— 用户要的是
|
|
363
|
+
`[脸][引擎名][运行中·秒数]` 同一行,正文仍整行在下面。 */
|
|
364
|
+
var ico = null;
|
|
365
|
+
for (var n = msg.firstChild; n; n = n.nextSibling) {
|
|
366
|
+
if (n.nodeType === 1 && n.classList && n.classList.contains('qa-ico')) { ico = n; break; }
|
|
367
|
+
}
|
|
368
|
+
msg.insertBefore(w.el, ico ? ico.nextSibling : msg.firstChild);
|
|
369
|
+
msg.classList.add(RUNLIVE_CLS);
|
|
370
|
+
/* 这一行放不下了整串「引擎 · 模型」,先把小标收短(start 事件那条重写路径
|
|
371
|
+
会带着 ctx.whoModelOnly 跟上,见 09-stream-apply 的 start 分支)。
|
|
372
|
+
收尾时由 clearWorking 显式写回整串 —— 摘类不管文字(`.qa-runlive` 只管排版)。 */
|
|
373
|
+
if (state.qa && state.qa.h && typeof state.qa.h.setWho === 'function') {
|
|
374
|
+
state.qa.h.setWho(state.engine, state.model, true);
|
|
375
|
+
}
|
|
510
376
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
377
|
+
/* ⚠️ **不画文字、也不单独占一行**(2026-10-20 用户:「agent运行中 的文字就不需要」→
|
|
378
|
+
「agent运行中还在」→「不需要这个效果」):
|
|
379
|
+
文字以前靠 `.qa-runlive` 那条 CSS 隐藏 —— 只要「并进消息」那一步没生效(消息还没建出来 /
|
|
380
|
+
类没盖上),那串字就照旧露出来;独立一行则在没有正文时伪造出一条「假 bot 行」。
|
|
381
|
+
现在节点**只活在 state.working 里**,等这一轮的消息建出来再并进去(mountWorking);
|
|
382
|
+
在那之前不落画布,界面上什么都不显示。 */
|
|
383
|
+
function setWorking() {
|
|
514
384
|
clearWorking();
|
|
515
385
|
var d = el('div', 'working');
|
|
516
386
|
d.appendChild(el('div', 'working__sp'));
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
387
|
+
var act = el('span', 'working__act');
|
|
388
|
+
var dur = el('span', 'working__d');
|
|
389
|
+
d.appendChild(act); d.appendChild(dur);
|
|
390
|
+
state.working = { el: d, act: act, d: dur };
|
|
391
|
+
WORK_T0 = Date.now();
|
|
392
|
+
workElapsed();
|
|
393
|
+
WORK_TICK = setInterval(workElapsed, 100); // 100ms:与 PC 的秒数跳动同频
|
|
394
|
+
mountWorking(); // 这一轮的消息已经在了就直接并进去
|
|
520
395
|
}
|
|
521
396
|
function clearWorking() {
|
|
522
|
-
if (
|
|
397
|
+
if (WORK_TICK) { clearInterval(WORK_TICK); WORK_TICK = 0; }
|
|
398
|
+
var w = state.working;
|
|
399
|
+
if (!w) return;
|
|
400
|
+
/* 类挂在**父节点**(那条消息)上,摘节点之前先摘类 —— 否则下一轮会带着上一轮的
|
|
401
|
+
「运行中」标记进来(那会让新消息一建出来就是运行态)。 */
|
|
402
|
+
var host = w.el.parentNode;
|
|
403
|
+
if (host && host.classList) host.classList.remove(RUNLIVE_CLS);
|
|
404
|
+
/* ⚠️ 小标要**显式**恢复整串,摘类不会 —— `setWho` 写的是 textContent,
|
|
405
|
+
`.qa-runlive` 那条 CSS 只管排版不管文字。这条消息收尾后还要留在画布上当历史,
|
|
406
|
+
得让它显示回「引擎 · 模型」(runlive 时按 mountWorking 收短过)。
|
|
407
|
+
⚠️ 必须在摘 `state.qa` 之前做:`setWho` 挂在这一轮的句柄上。 */
|
|
408
|
+
if (state.qa && state.qa.h && typeof state.qa.h.setWho === 'function') {
|
|
409
|
+
state.qa.h.setWho(state.engine, state.model, false);
|
|
410
|
+
}
|
|
411
|
+
w.el.remove();
|
|
412
|
+
state.working = null;
|
|
523
413
|
}
|
|
524
414
|
|
|
525
|
-
/* ToolPlaceholder(:2199-2270) Clock / CircleAlert + 13sp Bold + 状态胶囊 */
|
|
526
|
-
function phItem(text, kind, status) {
|
|
527
|
-
var wrap = tgroup();
|
|
528
|
-
var d = el('div', 'ph' + (kind === 'err' ? ' ph--err' : ''));
|
|
529
|
-
var row = el('div', 'ph__row');
|
|
530
|
-
row.appendChild(box('ph__ico', kind === 'err' ? 'i-alert' : 'i-clock', 16));
|
|
531
|
-
row.appendChild(el('span', 'ph__t', text));
|
|
532
|
-
if (status) row.appendChild(el('span', 'pill' + (kind === 'err' ? ' pill--err' : ''), status));
|
|
533
|
-
d.appendChild(row);
|
|
534
|
-
wrap.appendChild(d);
|
|
535
|
-
return wrap;
|
|
536
|
-
}
|
|
537
|
-
/* CompactTimelineSeparator(:2272-2315) */
|
|
538
|
-
function sepItem(text) {
|
|
539
|
-
var wrap = tgroup();
|
|
540
|
-
var d = el('div', 'sep');
|
|
541
|
-
d.appendChild(el('div', 'sep__line'));
|
|
542
|
-
d.appendChild(el('span', 'sep__t', text));
|
|
543
|
-
d.appendChild(el('div', 'sep__line'));
|
|
544
|
-
wrap.appendChild(d);
|
|
545
|
-
return wrap;
|
|
546
|
-
}
|
|
547
415
|
/* AuthErrorNotice(Components.kt:88-117) */
|
|
548
416
|
function noticeItem(text) {
|
|
549
417
|
var d = el('div', 'notice');
|
|
@@ -552,184 +420,269 @@
|
|
|
552
420
|
pushItem(d, true);
|
|
553
421
|
}
|
|
554
422
|
|
|
555
|
-
/*
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
423
|
+
/* ⚠️ 2026-10-16(块③ 三端瘦身):**「事件 → 画面」整层已交给对话内核**(`shared/conv/*`)——
|
|
424
|
+
工具归类与人话标题、diff 判定与推导、标记行、斜杠命令生命周期、引擎产出的图片、
|
|
425
|
+
思考块与工具组、收尾行…… 这些本文件里那套实现(约 1000 行)连同各自的注释一起删了:
|
|
426
|
+
留着只会是第二份真相,漂移了最难查。要让画面多出什么形态,改内核那一份。
|
|
427
|
+
|
|
428
|
+
本文件从此只管**外壳**:输入区 / 附件 / 引擎与授权选择器 / 传输层,
|
|
429
|
+
外加把内核要的宿主符号接上(`shared/host-glue.js` 给值型与桩,本文件的 MagicHost 桥给真转发)。 */
|
|
430
|
+
|
|
431
|
+
/* ── 事件 → 界面(历史与实时共用同一条路径,靠 seq 去重) ── */
|
|
432
|
+
/* ── 事件入口:去重 → 认锚点 → **归一** → 逐条落笔 ──
|
|
433
|
+
⚠️ 归一那一步(2026-10-16 阶段三)是引用对话内核 `shared/conv/08-ask-event-map.js` 的
|
|
434
|
+
`qaEventNormalize`:本文件以前只认扁平事件 —— 引擎原生的 `assistant` / `user`
|
|
435
|
+
(正文与工具调用装在 `message.content[]` 里)到这里会掉进末尾「未识别的事件」兜底行,
|
|
436
|
+
手机上「模型答了但什么都没有」,而 PC 上一切正常。
|
|
437
|
+
现在先归一、再逐条画:扁平事件原样通过(行为不变),原生形态被翻译成同一批扁平事件。
|
|
438
|
+
⚠️ seq 去重与会话锚点留在**入口**这一层(合金帧只带一次 session_id,翻译出来的子事件不带)。 */
|
|
439
|
+
function render(ev) {
|
|
440
|
+
/* ⚠️ seq 去重**只服务于实时流**(同一帧可能重复投递)。
|
|
441
|
+
回放**不走它**(2026-10-16 修,用户报「多轮对话只显示一轮」):
|
|
442
|
+
会话日志是**跨轮追加**的,而老的日志里每一轮的 seq 都从 1 重来(每轮一个新 core
|
|
443
|
+
进程),回放时按 seq 去重会把第二轮起的事件全当成「重复帧」丢掉 ——
|
|
444
|
+
界面上就只剩第一轮。回放的权威是文件本身,那里不可能有重复行,跳过是安全的。
|
|
445
|
+
|
|
446
|
+
2026-10-20:网关合成的「用户提问」事件**没有 seq**(seq 固定 0,见
|
|
447
|
+
internal/client/service.go 的 appendPrompt)—— 用它的 `prompt_id` 当去重键。
|
|
448
|
+
为什么需要:core 首行到达之前,补拉会以 after=0 反复轮询,同一句提问
|
|
449
|
+
否则会被画很多遍;它也不该推进游标(游标只认 core 流的 seq)。 */
|
|
450
|
+
var key = ev.seq != null ? ev.seq : (ev.prompt_id ? 'p:' + ev.prompt_id : null);
|
|
451
|
+
if (key != null && !state.replay) {
|
|
452
|
+
if (state.renderedSeq[key]) return;
|
|
453
|
+
state.renderedSeq[key] = true;
|
|
454
|
+
/* 断点续传的游标:只认**实时流**收到的最大 seq(回放的 seq 是跨轮文件级,
|
|
455
|
+
与流 seq 不同口径 —— 拿回放去推进游标会让续传错位)。 */
|
|
456
|
+
if (ev.seq != null) {
|
|
457
|
+
var seqN = Number(ev.seq);
|
|
458
|
+
if (isFinite(seqN) && seqN > state.lastSeq) state.lastSeq = seqN;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/* 会话锚点:**任何**带 session_id 的事件都认(--events 下 core 一拿到 id 就每行都带;
|
|
462
|
+
收尾的 result 行恒带)。以前只在 turn_end / turn_failed 里认 —— 而 dsh 走 headless
|
|
463
|
+
的轮次(续接轮、以及 SDK 通道不可用时的回退轮)那条 turn_end 不带 id,客户端就永远
|
|
464
|
+
学不到锚点,下一步追问被发成了**新会话**(真实报障:追问变成了新任务)。 */
|
|
465
|
+
if (ev.session_id) state.session = ev.session_id;
|
|
466
|
+
/* ⚠️ 2026-10-16(块②后半):这幅画面**不再由本文件画** ——
|
|
467
|
+
「一条事件流 → 对话」的映射收归对话内核一份(`shared/conv/09-stream-apply.js`),
|
|
468
|
+
组件这边只递事件、递画布、把回调接回自己的状态。见下面 kernelApply。 */
|
|
469
|
+
kernelApply(ev);
|
|
564
470
|
}
|
|
565
|
-
|
|
566
|
-
|
|
471
|
+
|
|
472
|
+
/* ══════════ 事件 → 画面:走内核那一份实现 ══════════
|
|
473
|
+
三端(掌天瓶工作台 / 本组件 · PC 宿主 / 本组件 · 移动宿主)共用同一份脚本,
|
|
474
|
+
所以这里**只有四个调用**:
|
|
475
|
+
qaRenderMine(text, atts) —— 用户那条气泡
|
|
476
|
+
h = qaRenderAnswer(#qa-stream) —— 拿这一轮的渲染句柄
|
|
477
|
+
qaStreamApply(ev, ctx) —— 把每条事件交给内核
|
|
478
|
+
qaRenderDone(ok, label, …) —— 收尾行
|
|
479
|
+
本文件仍然负责的是**外壳**(输入区 / 附件 / 引擎与授权选择器 / 传输层)——
|
|
480
|
+
内核管「事件画成什么」,外壳管「这一屏长什么样、数据从哪来」。
|
|
481
|
+
|
|
482
|
+
── 喂之前要做的两件事(都是宿主这一层的活儿)──
|
|
483
|
+
① 落盘拼写:实时流用 `type`,落盘的 `<session_id>.jsonl` 用 `kind`
|
|
484
|
+
(internal/cli/sessionlog.go)—— 内核只认 `type`,这里翻一次。
|
|
485
|
+
② 会话锚点与 seq 去重留在**入口这一层**(见上面的 render):归一之后每条子事件
|
|
486
|
+
不带 session_id,而 seq 是**每轮从 1 重来**的(每起一个 core 进程一个新序列)。 */
|
|
487
|
+
/* ── 产物卡要的宿主桥(2026-10-20 用户:「也没显示会话产物,跟pc端统一逻辑」)──
|
|
488
|
+
内核(shared/conv/19-attachments.js)画产物只问宿主三件事:
|
|
489
|
+
window.desk.pathProbe({ base, paths }) —— 这些路径真的在盘上吗(一次批量)
|
|
490
|
+
window.desk.mediaUrl(abs) —— 本机文件怎么回读(<img>/<video> 的 src)
|
|
491
|
+
window.desk.pathRead({ path, base }) —— 文本产物怎么读(产物卡「查看」阅读用)
|
|
492
|
+
桌面壳由 Electron 主进程注入这一组;组件宿主(PC 网页 / 移动端)没有主进程,
|
|
493
|
+
但服务端就跑在 core 那台机器上 —— 用 transport 顶上即可(/desk/path-probe、/desk/media、/desk/path-read)。
|
|
494
|
+
⚠️ 已有的实现**不覆盖**(宿主那份是 IPC 直连,链路更短)。 */
|
|
495
|
+
(function installDeskProbe() {
|
|
496
|
+
if (!transport) return;
|
|
497
|
+
var d = window.desk;
|
|
498
|
+
if (!d || typeof d !== 'object') { d = window.desk = {}; }
|
|
499
|
+
if (typeof d.pathProbe !== 'function' && typeof transport.pathProbe === 'function') {
|
|
500
|
+
d.pathProbe = function (req) { return transport.pathProbe(req); };
|
|
501
|
+
}
|
|
502
|
+
if (typeof d.mediaUrl !== 'function' && typeof transport.fileUrl === 'function') {
|
|
503
|
+
d.mediaUrl = function (p) { return transport.fileUrl(p); };
|
|
504
|
+
}
|
|
505
|
+
if (typeof d.pathRead !== 'function' && typeof transport.pathRead === 'function') {
|
|
506
|
+
d.pathRead = function (req) { return transport.pathRead(req); };
|
|
507
|
+
}
|
|
508
|
+
})();
|
|
509
|
+
|
|
510
|
+
/* 本轮产物:与 PC 工作台 / magic-test 用**同一套**(内核 qaTurnArtsTrack,见 19-attachments.js)——
|
|
511
|
+
收(写类工具参数 / 工具输出 / notice 里的产物路径)与画(封口时补在这条消息末尾)。
|
|
512
|
+
不接这一下,回放与实时都只有正文里写出来的那些图,没写进正文的产物一张不显示。 */
|
|
513
|
+
var turnArts = (typeof qaTurnArtsTrack === 'function') ? qaTurnArtsTrack() : null;
|
|
514
|
+
|
|
515
|
+
function kernelApply(ev) {
|
|
516
|
+
if (!ev || typeof ev !== 'object') return;
|
|
517
|
+
var t = ev.type || ev.kind;
|
|
518
|
+
if (!t) return; // 畸形帧(连类型都没有)
|
|
519
|
+
var e = ev.type ? ev : Object.assign({}, ev, { type: t });
|
|
520
|
+
if (t === 'start') {
|
|
521
|
+
/* 一轮真正用的引擎 / 模型(配置里的模型链会自动降级)→ 选择器跟着改口 */
|
|
522
|
+
if (e.engine) state.engine = String(e.engine);
|
|
523
|
+
if (e.model) state.model = String(e.model);
|
|
524
|
+
syncPickers();
|
|
525
|
+
}
|
|
526
|
+
if (t === 'ask') state.awaitingAnswer = true; // 引擎在等这一问的答案(见 MagicHost.send)
|
|
527
|
+
/* 画布上已经有东西了 → 撤掉那句空态说明(它只在一条都还没画时该在) */
|
|
528
|
+
var em = document.getElementById('qa-stream');
|
|
529
|
+
var empty = em && em.querySelector('.conv__empty');
|
|
530
|
+
if (empty) empty.remove();
|
|
531
|
+
/* ⚠️ 历史里**用户那条**(落盘 `kind:"user"`,见 internal/cli/sessionlog.go 的 Attachments 注释)
|
|
532
|
+
不是渲染层事件 —— 内核的事件流里没有「用户气泡」这一支(它在 `qaStreamApply` 的**忽略清单**里,
|
|
533
|
+
免得实时流把用户刚发的那条画两遍)。回放时得**宿主自己**调 qaRenderMine ——
|
|
534
|
+
magic-test 的 27-conv-log.js 与 pc 工作台的 93-rail.js 都是这么做的。
|
|
535
|
+
2026-10-17 用户报:「移动端看不到会话历史」—— 症状是历史里的助手那一侧画出来了、
|
|
536
|
+
用户那句提问**一个字都没有**(问到哪了全靠猜)。 */
|
|
537
|
+
if (t === 'user') {
|
|
538
|
+
/* 历史回放里「用户那句」就是一轮的新起点:上一轮的已收尾标记到此作废,
|
|
539
|
+
否则第二轮的助手消息会被 qaEnsureAnswer 拒开(见 state.qaClosed 注释)。 */
|
|
540
|
+
state.qaClosed = false;
|
|
541
|
+
/* ⚠️ 回放时还必须**把上一轮封口**:上一轮若被中断(没有 turn_end / result),
|
|
542
|
+
kernelDone 从未执行,state.qa 会一直挂着 —— 下一轮的思考 / 工具 / 正文就全被
|
|
543
|
+
写进上一轮那条气泡里,而最新一轮看上去一个字都没有(真实报障:「切回来还是没好」)。
|
|
544
|
+
封口走内核的 `h.finish()`(与实时收尾同一动作:落定工具卡、并进「任务耗时」组),
|
|
545
|
+
但**不画收尾行** —— 那轮没有终态事件,写「已完成」是编的。 */
|
|
546
|
+
if (state.replay) finishReplayRound();
|
|
547
|
+
if (state.replay) {
|
|
548
|
+
state.replayT0 = replayAt(e); state.replayLastAt = state.replayT0;
|
|
549
|
+
}
|
|
550
|
+
qaRenderMine(String(e.text || ''), Array.isArray(e.attachments) ? e.attachments : []);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
/* 产物收集:这一轮的工具参数 / 工具输出 / notice 里出现的落盘路径(见 turnArts 注释)。
|
|
554
|
+
内核那一支只认 `kind`,所以把实时流的 `type` 归一成 `kind` 再喂。 */
|
|
555
|
+
if (turnArts) turnArts.note(Object.assign({}, e, { kind: t }));
|
|
556
|
+
/* 回放:记本轮真实时间轴,并**先**把时长写到句柄上(turn_end 那条一进来就会 finish 并定格组头,
|
|
557
|
+
喂完再改就来不及了 —— 与 27-conv-ledger 的 qaSessionReplay 同一口径)。 */
|
|
558
|
+
if (state.replay) {
|
|
559
|
+
var at = replayAt(e);
|
|
560
|
+
if (at) {
|
|
561
|
+
if (!state.replayT0) state.replayT0 = at;
|
|
562
|
+
if (at > state.replayLastAt) state.replayLastAt = at;
|
|
563
|
+
}
|
|
564
|
+
if (state.qa && state.qa.h && state.qa.h.el) {
|
|
565
|
+
state.qa.h.el.__qaReplay = true;
|
|
566
|
+
state.qa.h.el.__qaMs = replaySpan();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
qaStreamApply(e, qaCtx());
|
|
567
570
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
var
|
|
571
|
-
|
|
572
|
-
var head = el('button', 'tool__head');
|
|
573
|
-
head.type = 'button';
|
|
574
|
-
head.setAttribute('aria-expanded', 'false');
|
|
575
|
-
head.appendChild(box('chev', 'i-chev', 16));
|
|
576
|
-
head.appendChild(box('tool__ico', toolIconId(name), 16));
|
|
577
|
-
var sum = el('span', 'tool__sum', String(opts.summary || name || '工具'));
|
|
578
|
-
head.appendChild(sum);
|
|
579
|
-
if (opts.pill) head.appendChild(el('span', 'pill' + (opts.failed ? ' pill--err' : ''), opts.pill));
|
|
580
|
-
d.appendChild(head);
|
|
581
|
-
var detail = el('div', 'tool__detail');
|
|
582
|
-
detail.style.display = 'none';
|
|
583
|
-
d.appendChild(detail);
|
|
584
|
-
head.onclick = function () {
|
|
585
|
-
var open = head.getAttribute('aria-expanded') !== 'true';
|
|
586
|
-
head.setAttribute('aria-expanded', String(open));
|
|
587
|
-
detail.style.display = open ? '' : 'none';
|
|
588
|
-
if (open) { try { if (navigator.vibrate) navigator.vibrate(12); } catch (e) {} } // :1656 触感
|
|
589
|
-
};
|
|
590
|
-
pushItem(d);
|
|
591
|
-
return { el: d, head: head, sum: sum, detail: detail, cmd: cmd };
|
|
571
|
+
/** 日志事件的墙钟(ISO → ms;解析不出来回 0) */
|
|
572
|
+
function replayAt(e) {
|
|
573
|
+
var t = Date.parse(String((e && e.at) || ''));
|
|
574
|
+
return isFinite(t) ? t : 0;
|
|
592
575
|
}
|
|
593
|
-
|
|
594
|
-
function
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
h.appendChild(el('span', 'cmdbar__label', label || '命令'));
|
|
598
|
-
h.appendChild(copyBtn(command));
|
|
599
|
-
w.appendChild(h);
|
|
600
|
-
var b = el('div', 'cmdbar__body');
|
|
601
|
-
b.appendChild(el('span', 'cmdbar__p', '$'));
|
|
602
|
-
b.appendChild(el('span', 'cmdbar__t', command));
|
|
603
|
-
w.appendChild(b);
|
|
604
|
-
return w;
|
|
576
|
+
/** 回放这一轮真实花了多久:拿不到(日志没带 `at`)就返回 null —— 宁可没有,不编 0.0s */
|
|
577
|
+
function replaySpan() {
|
|
578
|
+
if (!state.replayT0 || !state.replayLastAt || state.replayLastAt <= state.replayT0) return null;
|
|
579
|
+
return state.replayLastAt - state.replayT0;
|
|
605
580
|
}
|
|
606
|
-
|
|
607
|
-
function
|
|
608
|
-
var
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
h.
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
581
|
+
/** 回放里的一轮被封口(下一条 user 到来 / 日志读完):时长交给内核的「任务耗时」组头 */
|
|
582
|
+
function finishReplayRound() {
|
|
583
|
+
var q = state.qa;
|
|
584
|
+
state.qa = null;
|
|
585
|
+
if (!q || !q.h) return;
|
|
586
|
+
if (q.h.el) { q.h.el.__qaReplay = true; q.h.el.__qaMs = replaySpan(); }
|
|
587
|
+
/* 先补挂「本轮产物」再封口(在 qaRunEnd 拆卡之前 —— 与 27-conv-ledger 的 finish 同序)。 */
|
|
588
|
+
if (turnArts) { try { turnArts.flush(q.h); } catch (e0) {} }
|
|
589
|
+
/* qaRunEnd = finish + 摘掉「生成中」胶囊(内核的统一收口,见 03-chat-message)。
|
|
590
|
+
这里只 finish 会把那枚胶囊永久钉在画布上(回放里已经真实出现过)。 */
|
|
591
|
+
try { if (typeof qaRunEnd === 'function') qaRunEnd(q.h); else if (q.h.finish) q.h.finish(); }
|
|
592
|
+
catch (e) { /* 封口失败不影响后面的轮次 */ }
|
|
617
593
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
594
|
+
/** 这一轮的渲染句柄(每轮一个 `.qa-msg`):内核按 host 落笔,句柄管这一轮 */
|
|
595
|
+
function qaEnsureAnswer() {
|
|
596
|
+
/* 收尾后**不再开新的一笔**:迟到事件(result / end)到此为止(见 state.qaClosed)。 */
|
|
597
|
+
if (state.qaClosed) return null;
|
|
598
|
+
var s = thread();
|
|
599
|
+
if (!s) return null;
|
|
600
|
+
if (!state.qa || !state.qa.h) {
|
|
601
|
+
var h = qaRenderAnswer(s);
|
|
602
|
+
if (!h) return null;
|
|
603
|
+
/* 回放里新开的那一笔:时长同样只认日志 `at`(见 replaySpan),不按墙上时钟。 */
|
|
604
|
+
if (state.replay && h.el) { h.el.__qaReplay = true; h.el.__qaMs = replaySpan(); }
|
|
605
|
+
state.qa = { h: h, tools: new Map(), agents: new Map(), rid: 'conv-' + Date.now(), t0: Date.now(), ms: 0 };
|
|
606
|
+
/* 这条消息刚建出来 → 前面还挂在画布里的「运行中」现在有地方可并了(见 mountWorking)。
|
|
607
|
+
为什么必须在这里补:setWorking 早于这一轮的第一条事件,那时消息还不存在,
|
|
608
|
+
指示器只能先单独占一行;不补这一下,它会一直独占一行到收尾。 */
|
|
609
|
+
mountWorking();
|
|
622
610
|
}
|
|
611
|
+
return state.qa.h;
|
|
623
612
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
var
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
613
|
+
/** 内核要的 ctx(12 键,实测契约见 shared/conv/dom-map.md §5-1b —— 少一个就地 TypeError) */
|
|
614
|
+
function qaCtx() {
|
|
615
|
+
var h = qaEnsureAnswer();
|
|
616
|
+
/* 已收尾时 h 为 null:仍然给一份**结构完整**的 ctx(内核只认形状),
|
|
617
|
+
让迟到事件停在 qaStreamApplyOne 的 `if(!h)` 守卫上,而不是让这里解引用 null 炸掉。 */
|
|
618
|
+
var q = state.qa;
|
|
619
|
+
return {
|
|
620
|
+
h: h, tools: q ? q.tools : new Map(), agents: q ? q.agents : new Map(),
|
|
621
|
+
rid: q ? q.rid : '', taskId: state.session || '',
|
|
622
|
+
setMs: function (ms) { if (q) q.ms = Number(ms) || 0; },
|
|
623
|
+
/* 会话锚点:任何带 session_id 的事件当场认(内核自己已经在各分支里认过一遍了) */
|
|
624
|
+
onSession: function (sid) { if (sid) state.session = String(sid); },
|
|
625
|
+
/* 正文台账:重试 / 快照要用;result 那条是**权威全文**(覆盖增量) */
|
|
626
|
+
onText: function (t) { state.agentRaw += String(t || ''); },
|
|
627
|
+
onResult: function (t) { state.agentRaw = String(t || ''); },
|
|
628
|
+
/* 过程步骤(思考 / 工具 / 结果):组件这一层不留台账(历史由 core 的会话日志给) */
|
|
629
|
+
onStep: function () {},
|
|
630
|
+
/* 头像右边那枚小标在运行态只显示**模型名**(2026-10-20 用户:「只显示模型即可
|
|
631
|
+
不然现在换行了」):那一行并着秒数,「引擎 · 模型」整串太长会把秒数挤到换行。
|
|
632
|
+
⚠️ 这条**由本壳决定、内核执行**(内核才有那枚小标的节点与文案口径)——
|
|
633
|
+
其他宿主(PC 工作台)不传这一项就仍是整串,三端各自按自己的排布取舍。 */
|
|
634
|
+
whoModelOnly: true,
|
|
635
|
+
onError: function (e) {
|
|
636
|
+
/* 显示「根因」优先:`reason` 是 CLI 给的最内层原因(429 / 额度 / 认证),
|
|
637
|
+
`error` 是带引擎包装的完整链 —— 前 80 字常被包装词吃掉。 */
|
|
638
|
+
var why = String((e && e.reason) || '').trim();
|
|
639
|
+
kernelDone(false, '调用失败 · ' + (why ? why.slice(0, 200) : String((e && e.error) || '未知错误').slice(0, 80)));
|
|
640
|
+
},
|
|
641
|
+
onDone: function (e) { kernelDone(true, (e && e.stopped) ? '已停止' : '已完成'); },
|
|
642
|
+
};
|
|
639
643
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (!
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/* ── 事件 → 界面(历史与实时共用同一条路径,靠 seq 去重) ── */
|
|
659
|
-
function render(ev) {
|
|
660
|
-
if (ev.seq != null && state.renderedSeq[ev.seq]) return;
|
|
661
|
-
if (ev.seq != null) state.renderedSeq[ev.seq] = true;
|
|
662
|
-
/* ⚠️ 两套字段名都要认:
|
|
663
|
-
实时 SSE(magic-agent --stream --events)事件用 `type`;
|
|
664
|
-
落盘的 <session_id>.jsonl 用 `kind`(internal/cli/sessionlog.go 的 sessionEvent.Kind)。
|
|
665
|
-
只认 type 的后果是「从列表点进会话」历史整段空白 —— 静默失败,最难查。 */
|
|
666
|
-
var t = ev.type || ev.kind;
|
|
667
|
-
if (t === 'ready') {
|
|
668
|
-
setWorking('Agent 运行中');
|
|
669
|
-
} else if (t === 'thinking') {
|
|
670
|
-
var m = ensureRsn();
|
|
671
|
-
state.rsnRaw += ev.text || '';
|
|
672
|
-
m.md.innerHTML = mdToHtml(state.rsnRaw);
|
|
673
|
-
updateRsnTitle();
|
|
674
|
-
if (state.autoFollow) scrollBottom();
|
|
675
|
-
} else if (t === 'text') {
|
|
676
|
-
finalizeRsn();
|
|
677
|
-
ensureAgent();
|
|
678
|
-
state.agentRaw += ev.text || '';
|
|
679
|
-
scheduleMd();
|
|
680
|
-
} else if (t === 'tool_use') {
|
|
681
|
-
onToolUse(ev);
|
|
682
|
-
} else if (t === 'tool_result') {
|
|
683
|
-
onToolResult(ev);
|
|
684
|
-
} else if (t === 'user') {
|
|
685
|
-
// 历史里「用户自己那条」。core 目前只落引擎事件,这条是给落盘后预留的;
|
|
686
|
-
// 认它不花成本,落盘一上线历史就自然有气泡。
|
|
687
|
-
finalizeRsn(); finalizeAgent(true); stopPrevTool(); clearWorking();
|
|
688
|
-
userBubble(String(ev.text || ''), false);
|
|
689
|
-
} else if (t === 'turn_end') {
|
|
690
|
-
if (ev.session_id) state.session = ev.session_id;
|
|
691
|
-
clearWorking();
|
|
692
|
-
finalizeRsn();
|
|
693
|
-
// 兜底:个别引擎只在 turn_end 带整轮正文(正文增量一条都没到时用它)
|
|
694
|
-
if (!state.cur && !state.agentRaw && ev.text) { ensureAgent(); state.agentRaw = ev.text; }
|
|
695
|
-
finalizeAgent(true);
|
|
696
|
-
stopPrevTool();
|
|
697
|
-
} else if (t === 'result') {
|
|
698
|
-
clearWorking();
|
|
699
|
-
finalizeRsn();
|
|
700
|
-
finalizeAgent(true);
|
|
701
|
-
stopPrevTool();
|
|
702
|
-
state.resultSeen = true;
|
|
703
|
-
pushItem(sepItem('完成 · ' + (ev.latency_ms || 0) + 'ms'));
|
|
704
|
-
} else if (t === 'error') {
|
|
705
|
-
clearWorking();
|
|
706
|
-
finalizeRsn();
|
|
707
|
-
finalizeAgent(true);
|
|
708
|
-
stopPrevTool();
|
|
709
|
-
pushItem(phItem('本轮失败:' + (ev.reason || ev.error || ''), 'err', 'failed'), true);
|
|
710
|
-
} else if (t === 'ask') {
|
|
711
|
-
finalizeRsn(); finalizeAgent(true); stopPrevTool();
|
|
712
|
-
/* 审批 / 提问卡。原先这里只弹一行 notice —— 而 ask 是 headless 下**唯一**能
|
|
713
|
-
喂答案的通道(core 的 --control answer),只弹一行等于把这条路掐断了:
|
|
714
|
-
引擎卡在等待批准,手机上却没有任何办法推进它。
|
|
715
|
-
结构与桌面版 approval() 对齐(同一份 events 契约),样式按 AA 的状态胶囊走。 */
|
|
716
|
-
approvalCard(ev);
|
|
717
|
-
} else if (t === 'interrupted' || t === 'stopped') {
|
|
718
|
-
clearWorking();
|
|
719
|
-
finalizeRsn();
|
|
720
|
-
finalizeAgent(true);
|
|
721
|
-
stopPrevTool();
|
|
722
|
-
pushItem(phItem(t === 'interrupted' ? '已中断' : '已停止', 'ok', null));
|
|
723
|
-
}
|
|
644
|
+
/** 一轮收尾(成功 / 失败 / 被停):内核的收尾动作已经做完(收思考区 / 工具卡 / 运行胶囊),
|
|
645
|
+
* 这一层只补**收尾行**(状态语 + 总耗时,取墙上时钟 —— 与掌天瓶那边同一口径)。 */
|
|
646
|
+
function kernelDone(ok, label) {
|
|
647
|
+
var q = state.qa; if (!q) return; // 幂等:终态可能来两条(turn_end 之后还有 end)
|
|
648
|
+
state.qa = null;
|
|
649
|
+
state.qaClosed = true; // 迟到事件不再开新一笔(见 state.qaClosed)
|
|
650
|
+
state.awaitingAnswer = false;
|
|
651
|
+
/* 耗时只认**真实观测到的**:实时流取墙上时钟(或 result 的 latency_ms);
|
|
652
|
+
续传轮从日志里那条 user 的 `at` 起算(见 state.resumeT0);
|
|
653
|
+
回放取日志 `at` 算出的本轮间隔,拿不到就**不显示** —— 不能把「回放花掉的几毫秒」
|
|
654
|
+
写成「总耗时 0.0s」糊弄人(2026-10-20 报障)。 */
|
|
655
|
+
var ms = q.ms || (state.replay ? replaySpan() : Date.now() - (state.resumeT0 || q.t0));
|
|
656
|
+
/* 收尾行落下之前补挂「本轮产物」(这一步只追加节点,不影响收尾行的位置)。 */
|
|
657
|
+
if (turnArts) { try { turnArts.flush(q.h); } catch (e0) {} }
|
|
658
|
+
qaRenderDone(ok, label, thread(), ms);
|
|
659
|
+
if (opts.onBusy) { try { opts.onBusy(false); } catch (err) {} }
|
|
724
660
|
}
|
|
725
661
|
|
|
726
662
|
function clearThread() {
|
|
663
|
+
/* 换会话先停掉上一轮的断线续传轮询:否则补拉回来的事件会落进新会话的画面。 */
|
|
664
|
+
if (state.resumeStop) { var rs = state.resumeStop; state.resumeStop = null; try { rs(); } catch (e) {} }
|
|
727
665
|
thread().innerHTML = '';
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
666
|
+
/* 「加载更早」入口是画布的**常驻第一个孩子**(内核只往画布尾追加,不会碰它)——
|
|
667
|
+
innerHTML 清空会把它一起带走,这里补回来(显隐由 syncMore 按 hist 决定)。 */
|
|
668
|
+
if (moreBtn && !moreBtn.parentNode) threadEl.insertBefore(moreBtn, threadEl.firstChild);
|
|
669
|
+
/* 骨架屏同理(它也是常驻位置上的独立节点):清空画布就把「上一轮还在读」这件事作废,
|
|
670
|
+
否则迟到的 140ms 计时器会在新画布上补一块谁都读不到结果的占位。 */
|
|
671
|
+
endHistLoading(false);
|
|
672
|
+
state.hist = null;
|
|
673
|
+
syncMore();
|
|
674
|
+
if (typeof qaScrollReset === 'function') { try { qaScrollReset(); } catch (e) {} }
|
|
675
|
+
state.agentRaw = '';
|
|
676
|
+
state.lastSeq = 0;
|
|
677
|
+
/* 换会话:这一轮的渲染句柄也归零(否则下一条事件会落进**上一个会话**的那条消息里) */
|
|
678
|
+
if (turnArts) { try { turnArts.flush(null); } catch (e0) {} } // 上一会话残留的产物路径不带走
|
|
679
|
+
state.qa = null;
|
|
680
|
+
state.qaClosed = false;
|
|
681
|
+
state.replayT0 = 0; state.replayLastAt = 0;
|
|
682
|
+
state.resumeT0 = 0;
|
|
683
|
+
state.awaitingAnswer = false;
|
|
684
|
+
clearWorking(); // 递带停掉秒表(只置 null 会让定时器空转)
|
|
731
685
|
state.autoFollow = true;
|
|
732
|
-
copyBin.length = 0;
|
|
733
686
|
if (mdTimer) { clearTimeout(mdTimer); mdTimer = 0; }
|
|
734
687
|
nearBottom(); syncBottomBtn();
|
|
735
688
|
}
|
|
@@ -737,7 +690,6 @@
|
|
|
737
690
|
$('btnBottom').classList.toggle('tobottom--on', !state.autoFollow && thread().scrollHeight > thread().clientHeight + 80);
|
|
738
691
|
}
|
|
739
692
|
|
|
740
|
-
|
|
741
693
|
/* 控制通道:打断 / 收工 / 回答案。op 的语义与 core 的 --control 同协议;
|
|
742
694
|
具体怎么送到 core 由 transport.control 决定(HTTP / IPC / CLI 都可能)。
|
|
743
695
|
⚠️ 没有 askId 就如实说,不要静默失败 —— 一个按下去没反应的按钮比报错更难查。
|
|
@@ -762,73 +714,10 @@
|
|
|
762
714
|
/* ── 审批 / 提问卡 ──
|
|
763
715
|
与桌面版 approval() 同一套 events 契约(CLIENT-CONTRACT.md 的 ask 事件):
|
|
764
716
|
ev.ask.questions[] 每题带 options[],multi_select 为真时是多选。
|
|
765
|
-
答复走 control 的 op:'answer' —— 与 core 的 --control answer 同协议。
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
var head = el('div', 'askcard__head');
|
|
770
|
-
head.appendChild(el('span', 'askcard__dot'));
|
|
771
|
-
head.appendChild(el('span', null, ask.kind === 'approval' ? '需要授权' : '需要确认'));
|
|
772
|
-
card.appendChild(head);
|
|
773
|
-
|
|
774
|
-
var qs = ask.questions || [];
|
|
775
|
-
if (!qs.length) {
|
|
776
|
-
card.appendChild(el('p', 'askcard__q', ev.text || '模型需要你的选择'));
|
|
777
|
-
var fb = el('button', 'askcard__btn askcard__btn--primary', '用输入框内容作答');
|
|
778
|
-
fb.onclick = function () { answerAsk(card, $('input').value.trim() || '是'); };
|
|
779
|
-
card.appendChild(fb);
|
|
780
|
-
pushItem(card, true);
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
qs.forEach(function (q) {
|
|
785
|
-
card.appendChild(el('p', 'askcard__q', (q.header ? '【' + q.header + '】' : '') + (q.text || '')));
|
|
786
|
-
var opts = q.options || [];
|
|
787
|
-
var chosen = [];
|
|
788
|
-
var row = el('div', 'askcard__opts');
|
|
789
|
-
if (q.multi_select) {
|
|
790
|
-
opts.forEach(function (o) {
|
|
791
|
-
var b = el('button', 'askcard__btn', o.label || '');
|
|
792
|
-
if (o.description) b.title = o.description;
|
|
793
|
-
b.onclick = function () {
|
|
794
|
-
var i = chosen.indexOf(o.label);
|
|
795
|
-
if (i >= 0) { chosen.splice(i, 1); b.classList.remove('askcard__btn--primary'); }
|
|
796
|
-
else { chosen.push(o.label); b.classList.add('askcard__btn--primary'); }
|
|
797
|
-
};
|
|
798
|
-
row.appendChild(b);
|
|
799
|
-
});
|
|
800
|
-
var sub = el('button', 'askcard__btn askcard__btn--primary', '提交');
|
|
801
|
-
sub.onclick = function () { if (chosen.length) answerAsk(card, chosen.join('、')); };
|
|
802
|
-
row.appendChild(sub);
|
|
803
|
-
} else {
|
|
804
|
-
opts.forEach(function (o) {
|
|
805
|
-
var b = el('button', 'askcard__opt');
|
|
806
|
-
b.appendChild(el('b', null, o.label || ''));
|
|
807
|
-
if (o.description) b.appendChild(el('span', null, o.description));
|
|
808
|
-
b.onclick = function () { answerAsk(card, o.label || ''); };
|
|
809
|
-
row.appendChild(b);
|
|
810
|
-
});
|
|
811
|
-
if (!opts.length) {
|
|
812
|
-
var fb2 = el('button', 'askcard__btn askcard__btn--primary', '用输入框内容作答');
|
|
813
|
-
fb2.onclick = function () { answerAsk(card, $('input').value.trim() || '是'); };
|
|
814
|
-
row.appendChild(fb2);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
card.appendChild(row);
|
|
818
|
-
});
|
|
819
|
-
pushItem(card, true);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
function answerAsk(card, text) {
|
|
823
|
-
control('answer', text).then(function (ok) {
|
|
824
|
-
if (!ok) return;
|
|
825
|
-
card.innerHTML = '';
|
|
826
|
-
var head = el('div', 'askcard__head');
|
|
827
|
-
head.appendChild(el('span', 'askcard__dot askcard__dot--ok'));
|
|
828
|
-
head.appendChild(el('span', null, '已答复:' + text));
|
|
829
|
-
card.appendChild(head);
|
|
830
|
-
});
|
|
831
|
-
}
|
|
717
|
+
答复走 control 的 op:'answer' —— 与 core 的 --control answer 同协议。
|
|
718
|
+
⚠️ 2026-10-16 阶段三:**题目形状的归一交给内核**(`qaAskFromEvent`,shared/conv/08-ask-event-map.js)——
|
|
719
|
+
两套拼写(`question`/`text`、`multiSelect`/`multi_select`)与空题过滤都由它做,
|
|
720
|
+
本文件以前只认其中一套,换一份引擎输出就会「卡上没题」。渲染这一层仍归本组件。 */
|
|
832
721
|
|
|
833
722
|
/* ── 三项基础能力:引擎 / 模型 / 授权 ──
|
|
834
723
|
移动端原先只有引擎选择,模型与授权都是空的,而这两样恰恰是「能不能安全用」的
|
|
@@ -861,27 +750,6 @@
|
|
|
861
750
|
return PERMISSION_TIERS[0];
|
|
862
751
|
}
|
|
863
752
|
|
|
864
|
-
function renderPermission() {
|
|
865
|
-
var b = $('btnPerm');
|
|
866
|
-
if (!b) return;
|
|
867
|
-
var t = tierOf(state.permission || '');
|
|
868
|
-
b.textContent = t.label;
|
|
869
|
-
b.setAttribute('data-tier', state.permission || '');
|
|
870
|
-
b.title = t.hint;
|
|
871
|
-
b.disabled = !engineSupportsPermission(state.engine);
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
/* 引擎选完,模型与授权的可选范围都要跟着变(模型属于引擎,授权取决于引擎支不支持)。 */
|
|
875
|
-
function onEnginePicked(name) {
|
|
876
|
-
state.engine = name;
|
|
877
|
-
var models = (state.engines || []).filter(function (x) { return x.engine === name; })[0];
|
|
878
|
-
var list = (models && models.models) || [];
|
|
879
|
-
if (list.indexOf(state.model) < 0) state.model = '';
|
|
880
|
-
if (!engineSupportsPermission(name)) state.permission = '';
|
|
881
|
-
if ($('chatTitle').textContent.indexOf('新会话') === 0) $('chatTitle').textContent = '新会话 · ' + name;
|
|
882
|
-
renderPermission();
|
|
883
|
-
}
|
|
884
|
-
|
|
885
753
|
/* ══════════ 组件外壳:对话区 + 输入区 ══════════
|
|
886
754
|
下面这些是宿主原先自己写的那部分(会话详情里的发送、底部面板、接线),
|
|
887
755
|
抽到组件里由组件负责 —— 这样两端不必各写一遍。 */
|
|
@@ -896,33 +764,148 @@
|
|
|
896
764
|
var text = ctx.input.value.trim();
|
|
897
765
|
if (!text || state.busy) return;
|
|
898
766
|
if (!state.engine) { noticeItem('还没有可用引擎:请先选一个引擎'); return; }
|
|
899
|
-
|
|
767
|
+
if ((state.attachments || []).some(function (a) { return a.uploading; })) { noticeItem('附件还在上传,请稍候'); return; }
|
|
768
|
+
state.lastPrompt = text;
|
|
769
|
+
/* 发出前先把附件快照下来:气泡缩略图走**回读地址**(不用 objectURL —— 发送后
|
|
770
|
+
clearAttachments 会 revoke,气泡若依赖它们会当场裂图);本轮的附件路径也一并带走。 */
|
|
771
|
+
var sent = (state.attachments || []).filter(function (a) { return a.path; });
|
|
772
|
+
var sentAtts = sent.map(function (a) { return { name: a.name, remoteUrl: a.remoteUrl }; });
|
|
773
|
+
var attPaths = sent.map(function (a) { return a.path; });
|
|
774
|
+
state.lastAttPaths = attPaths; // 记住本轮附件,重试时一起重发
|
|
775
|
+
/* 用户那条气泡由**内核**画(qaRenderMine)—— 与另外两端逐字同一份实现;
|
|
776
|
+
随消息发出的截图也一并交给它(第二个参数是附件清单)。
|
|
777
|
+
⚠️ 内核的「重试」走 MagicHost.send(…, {echo:false}):那条气泡本来就在画布上,
|
|
778
|
+
再画一次会有两条一模一样的问题(见上面挂桥那一段)。 */
|
|
779
|
+
if (!state.noEcho) qaRenderMine(text, sentAtts);
|
|
780
|
+
state.noEcho = false;
|
|
900
781
|
ctx.input.value = ''; autoGrow();
|
|
782
|
+
clearAttachments(); // 附件随本轮带走,输入框立刻清空(不等 runTurn 结束)
|
|
783
|
+
await runTurn(text, attPaths);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function runTurn(prompt, attPaths) {
|
|
901
787
|
state.busy = true; setSendMode('busy');
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
788
|
+
/* 发送前的等待门:宿主可在这里「按任务模式」把这一轮挂起(串行模式同空间已有任务时)。
|
|
789
|
+
info.isNew = 这是不是一段**新会话的首轮**(历史会话的追问 isNew=false、不受限)。 */
|
|
790
|
+
if (typeof opts.beforeAsk === 'function') {
|
|
791
|
+
setWorking();
|
|
792
|
+
try { await opts.beforeAsk({ isNew: !state.session, workspace: state.workspace, engine: state.engine, prompt: prompt }); } catch (e) {}
|
|
793
|
+
}
|
|
794
|
+
/* ⚠️ 收尾一律由**内核那一路**做(h.finish / qaRunEnd / qaRenderDone,见 kernelDone)——
|
|
795
|
+
这里不再补「收思考区 / 收工具卡 / 把过程并进任务耗时组」那一套(那是旧渲染器的活儿,
|
|
796
|
+
现在只会往内核画的画布上多添一笔)。这里只管**这一轮自己的状态**。 */
|
|
797
|
+
setWorking();
|
|
905
798
|
state.askId = null;
|
|
799
|
+
/* 新一轮 = 新的一笔:把上一轮的渲染句柄掀掉(下一笔由 qaEnsureAnswer 现开),
|
|
800
|
+
正文台账与「引擎在等答案」的标记一并复位。 */
|
|
801
|
+
if (turnArts) { try { turnArts.flush(null); } catch (e0) {} } // 上一轮没收口就成新轮:残留路径作废
|
|
802
|
+
state.qa = null;
|
|
803
|
+
state.qaClosed = false; // 新一轮开始:上一轮的收尾标记作废
|
|
804
|
+
state.agentRaw = '';
|
|
805
|
+
state.awaitingAnswer = false;
|
|
806
|
+
/* ⚠️ 每轮清空 seq 去重表:--events 的 seq 每起一个 core 进程都从 1 重来,
|
|
807
|
+
不清的话第二轮会撞上第一轮的 seq、被当成重复帧丢掉 ——
|
|
808
|
+
表现正是「第二条消息没有输出 / 不流式」。
|
|
809
|
+
续传游标同理每轮归零(它跟的是**本轮流 seq**)。 */
|
|
810
|
+
state.renderedSeq = {};
|
|
811
|
+
state.lastSeq = 0;
|
|
812
|
+
state.resumeT0 = 0; // 新一轮是本地发起的:耗时的起点就是 q.t0
|
|
813
|
+
if (state.resumeStop) { var rs0 = state.resumeStop; state.resumeStop = null; try { rs0(); } catch (e) {} }
|
|
906
814
|
if (opts.onBusy) { try { opts.onBusy(true); } catch (e) {} }
|
|
907
815
|
try {
|
|
908
|
-
var payload = { engine: state.engine, prompt:
|
|
816
|
+
var payload = { engine: state.engine, prompt: prompt };
|
|
909
817
|
if (state.model) payload.model = state.model;
|
|
910
|
-
|
|
911
|
-
|
|
818
|
+
if (state.workspace) payload.workspace = state.workspace;
|
|
819
|
+
if (attPaths && attPaths.length) payload.attachments = attPaths;
|
|
820
|
+
/* 空值就不传:让 core 用它自己的默认档,不替用户预设(安全语义)。 */
|
|
912
821
|
if (state.permission && engineSupportsPermission(state.engine)) payload.permission = state.permission;
|
|
913
822
|
if (state.session) payload.session = state.session;
|
|
914
|
-
/* 一轮的完整生命周期交给 transport:
|
|
915
|
-
onOpen → 拿到轮次 id(控制通道的唯一落点)
|
|
916
|
-
onEvent → 逐条 core 事件
|
|
917
|
-
onEnd → 本轮收尾(含 core 退出码)
|
|
918
|
-
transport 负责保证 onEnd 一定会被调到一次 —— 否则界面会永远卡在 busy。 */
|
|
919
823
|
await new Promise(function (resolve) {
|
|
824
|
+
var resolved = false;
|
|
825
|
+
var deliver = function () {
|
|
826
|
+
if (resolved) return;
|
|
827
|
+
resolved = true;
|
|
828
|
+
state.resumeStop = null;
|
|
829
|
+
resolve();
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
/* 兜底收尾:流断了、补拉也拿不到终态时才走这里。
|
|
833
|
+
一条终态事件(turn_end / error / end)都没收到时,画布上只剩提问,
|
|
834
|
+
回复区一片空白、也没有任何提示(2026-10-19 用户报「不显示回复」)。
|
|
835
|
+
这里补一条明确的收尾,别让「生成中」一直转下去。 */
|
|
836
|
+
var hardFinish = function (code, label) {
|
|
837
|
+
var failed = code !== 0 && code != null;
|
|
838
|
+
if (failed) noticeItem('core 退出码 ' + code);
|
|
839
|
+
if (!state.qaClosed) {
|
|
840
|
+
if (state.qa && state.qa.h) {
|
|
841
|
+
var h = state.qa.h;
|
|
842
|
+
/* 内核的收尾动作(关光标 / 摘「生成中」胶囊)只在终态事件路径里做
|
|
843
|
+
(qaRunEnd),这条兜底恰恰是「终态事件一条都没来」,
|
|
844
|
+
得自己补 —— 否则胶囊会一直转(2026-10-19 用户截图
|
|
845
|
+
「dsh · 生成中」与「连接中断」同屏)。 */
|
|
846
|
+
if (typeof qaRunEnd === 'function') qaRunEnd(h);
|
|
847
|
+
/* 一句正文/过程都没收到时,那条空助手气泡(只剩引擎抬头)也一并撤掉,
|
|
848
|
+
免得收尾行上方留一块空白。 */
|
|
849
|
+
var el = h.el;
|
|
850
|
+
if (el && !el.querySelector('.qa-t, .qa-think, .qa-tools, .qa-tool, .qa-ask, .qa-agent, .qa-mk, .qa-sep, .qa-img')) {
|
|
851
|
+
if (el.parentNode) el.parentNode.removeChild(el);
|
|
852
|
+
}
|
|
853
|
+
kernelDone(false, label || (failed ? '没有收到回复 · core 异常退出' : '没有收到回复 · 连接中断'));
|
|
854
|
+
} else {
|
|
855
|
+
noticeItem('本轮没有收到任何输出(连接可能已断开),请重试');
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
deliver();
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
/* 断线续传(2026-10-19):SSE 断了不代表本轮结束 —— 服务端把 core 的事件
|
|
862
|
+
留在按 ask id 的缓冲里,这里拿 lastSeq 增量补到终态或 done 为止,
|
|
863
|
+
而不是一刀切报「连接中断」。只有拿不到 askId / 宿主没实现 askEvents 时
|
|
864
|
+
才退回原来的兜底。
|
|
865
|
+
2026-10-20:补拉器抽成模块级的 tailAsk(打开运行中会话也用它),
|
|
866
|
+
这里只负责「补不到终态时怎么收尾」。 */
|
|
867
|
+
var resume = function (runID, code) {
|
|
868
|
+
tailAsk(runID, {
|
|
869
|
+
onEnd: function (reason, endCode, incomplete) {
|
|
870
|
+
if (reason === 'stopped' || reason === 'closed') return deliver();
|
|
871
|
+
/* 服务端已判定本轮结束:终态事件通常已在补拉里画过(qaClosed)。
|
|
872
|
+
极端情况下一个终态都没补到,也得落一条「已完成」,别留「生成中」。 */
|
|
873
|
+
if (reason === 'done') {
|
|
874
|
+
if (state.qaClosed) return deliver();
|
|
875
|
+
return hardFinish(0, incomplete ? '已完成 · 部分过程事件已丢失' : '已完成');
|
|
876
|
+
}
|
|
877
|
+
if (reason === 'missing') noticeItem('无法续传本轮事件(服务端未保留),请刷新会话');
|
|
878
|
+
else if (reason === 'unreachable') noticeItem('连接中断且无法续传,请刷新会话');
|
|
879
|
+
else if (reason === 'timeout') noticeItem('连接中断后补拉超时,回复可能不完整,请刷新会话');
|
|
880
|
+
endNow(endCode != null ? endCode : code, incomplete);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
/* 补不到终态时的收尾措辞:已经画出内容时说「没有收到回复」自相矛盾。 */
|
|
886
|
+
var endNow = function (code, incomplete) {
|
|
887
|
+
if (state.qaClosed) return deliver();
|
|
888
|
+
var hasText = !!(state.agentRaw && state.agentRaw.trim());
|
|
889
|
+
var label;
|
|
890
|
+
if (code !== 0 && code != null) {
|
|
891
|
+
label = hasText ? 'core 异常退出 · 回复可能不完整' : '没有收到回复 · core 异常退出';
|
|
892
|
+
} else if (incomplete || hasText) {
|
|
893
|
+
label = '连接中断 · 回复可能不完整';
|
|
894
|
+
} else {
|
|
895
|
+
label = '没有收到回复 · 连接中断';
|
|
896
|
+
}
|
|
897
|
+
hardFinish(code, label);
|
|
898
|
+
};
|
|
899
|
+
|
|
920
900
|
var settled = false;
|
|
921
901
|
var finish = function (code) {
|
|
922
902
|
if (settled) return;
|
|
923
903
|
settled = true;
|
|
924
|
-
if (
|
|
925
|
-
|
|
904
|
+
if (state.qaClosed) return deliver(); // 正常收尾,无事可做
|
|
905
|
+
if (state.askId && typeof transport.askEvents === 'function') {
|
|
906
|
+
return resume(state.askId, code);
|
|
907
|
+
}
|
|
908
|
+
hardFinish(code);
|
|
926
909
|
};
|
|
927
910
|
try {
|
|
928
911
|
transport.ask(payload, {
|
|
@@ -938,8 +921,10 @@
|
|
|
938
921
|
} catch (e) {
|
|
939
922
|
noticeItem('发送失败:' + (e.message || e));
|
|
940
923
|
} finally {
|
|
941
|
-
|
|
942
|
-
|
|
924
|
+
/* 这一轮的收尾行与工具组已由内核落定(turn_end / end → onDone → kernelDone)。
|
|
925
|
+
这里只摘掉本壳自己的「运行中」指示并把状态放开 —— 别再补「完成」这类行,
|
|
926
|
+
内核那条收尾行已经写了,补一条会变成画布上多一行重复的「完成」。 */
|
|
927
|
+
clearWorking();
|
|
943
928
|
state.busy = false; state.askId = null; setSendMode('idle');
|
|
944
929
|
if (opts.onBusy) { try { opts.onBusy(false); } catch (e) {} }
|
|
945
930
|
}
|
|
@@ -956,120 +941,704 @@
|
|
|
956
941
|
b.appendChild(svgIcon(mode === 'busy' ? 'i-clock' : 'i-up', 17));
|
|
957
942
|
b.setAttribute('aria-label', mode === 'busy' ? '中断' : '发送');
|
|
958
943
|
b.title = mode === 'busy' ? '中断这一轮' : '发送';
|
|
944
|
+
/* 「生成中」提示行(2026-10-20 用户:「两个胶囊合并成1个 在下面显示生成中效果即可」):
|
|
945
|
+
这枚胶囊**下面**那一行小字,就是合并后「这一轮在跑」的表达 ——
|
|
946
|
+
画布底部内核那枚同文案的胶囊在本壳不画(见 conversation.css)。
|
|
947
|
+
⚠️ 跟着 busy 走(setSendMode 是 busy 的唯一出口:发一轮 / 收一轮 / 续传轮
|
|
948
|
+
都经过它),所以不需要另开一个状态位。 */
|
|
949
|
+
if (ctx.genRow) ctx.genRow.classList.toggle('on', mode === 'busy');
|
|
959
950
|
}
|
|
960
951
|
|
|
961
|
-
/*
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
if (!en.ok) return;
|
|
1024
|
-
state.engine = en.engine;
|
|
1025
|
-
if (models.indexOf(state.model) < 0) state.model = '';
|
|
1026
|
-
if (!engineSupportsPermission(en.engine)) state.permission = '';
|
|
1027
|
-
syncPickers();
|
|
1028
|
-
});
|
|
1029
|
-
if (!en.ok) { row.disabled = true; p.appendChild(row); return; }
|
|
1030
|
-
p.appendChild(row);
|
|
1031
|
-
if (models.length) {
|
|
1032
|
-
p.appendChild(el('div', 'pick__cap', ' └ ' + en.engine + ' 的模型'));
|
|
1033
|
-
p.appendChild(pickRow('默认模型', '不传 -m,由引擎自己挑',
|
|
1034
|
-
state.engine === en.engine && !state.model, function () {
|
|
1035
|
-
state.engine = en.engine; state.model = ''; syncPickers();
|
|
1036
|
-
}));
|
|
1037
|
-
models.forEach(function (n) {
|
|
1038
|
-
p.appendChild(pickRow(n, '', state.engine === en.engine && state.model === n, function () {
|
|
1039
|
-
state.engine = en.engine; state.model = n; syncPickers();
|
|
1040
|
-
}));
|
|
1041
|
-
});
|
|
952
|
+
/* ══════════ 底部抽屉组件 Sheet(引擎 / 模型 / 授权共用)══════════
|
|
953
|
+
一个实例统一负责:scrim、贴底抽屉、抓条、标题栏、可滚动内容区、层级、
|
|
954
|
+
安全区、关闭(scrim / Esc / 再点同一按钮)。调用方只写内容。
|
|
955
|
+
之前这套逻辑散在 showPick/pickRow 里、层级只有 301 —— 被 in-app 浏览器底部栏
|
|
956
|
+
和叠加元素盖住;现在收成一个组件:层级拉高、高度用 dvh 贴合真实可视区。 */
|
|
957
|
+
var Sheet = (function () {
|
|
958
|
+
var scrim = null, panel = null, bodyEl = null, titleEl = null, curAnchor = null;
|
|
959
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
|
|
960
|
+
function build() {
|
|
961
|
+
scrim = el('div', 'pick-scrim');
|
|
962
|
+
scrim.onclick = close;
|
|
963
|
+
panel = el('div', 'pick pick--on');
|
|
964
|
+
panel.appendChild(el('div', 'pick__grab'));
|
|
965
|
+
var head = el('div', 'pick__head');
|
|
966
|
+
titleEl = el('span', 'pick__title');
|
|
967
|
+
head.appendChild(titleEl);
|
|
968
|
+
var x = el('button', 'pick__x', '✕');
|
|
969
|
+
x.type = 'button';
|
|
970
|
+
x.setAttribute('aria-label', '关闭');
|
|
971
|
+
x.onclick = close;
|
|
972
|
+
head.appendChild(x);
|
|
973
|
+
panel.appendChild(head);
|
|
974
|
+
bodyEl = el('div', 'pick__body');
|
|
975
|
+
panel.appendChild(bodyEl);
|
|
976
|
+
document.body.appendChild(scrim);
|
|
977
|
+
document.body.appendChild(panel);
|
|
978
|
+
document.addEventListener('keydown', onKey, true);
|
|
979
|
+
}
|
|
980
|
+
function close() {
|
|
981
|
+
if (!panel) return;
|
|
982
|
+
document.removeEventListener('keydown', onKey, true);
|
|
983
|
+
panel.remove(); scrim.remove();
|
|
984
|
+
scrim = null; panel = null; bodyEl = null; titleEl = null; curAnchor = null;
|
|
985
|
+
}
|
|
986
|
+
/* 内容 API:title(text) / row({label,desc,selected,danger,keepOpen,onPick}) / back(label,fn) / node(el) */
|
|
987
|
+
function render(fn) {
|
|
988
|
+
bodyEl.innerHTML = '';
|
|
989
|
+
fn({
|
|
990
|
+
title: function (t) { titleEl.textContent = t || ''; },
|
|
991
|
+
node: function (n) { bodyEl.appendChild(n); },
|
|
992
|
+
cap: function (t) { bodyEl.appendChild(el('div', 'pick__cap', t)); },
|
|
993
|
+
row: function (o) {
|
|
994
|
+
var b = el('button', 'pick__row' + (o.danger ? ' pick__danger' : ''));
|
|
995
|
+
b.type = 'button';
|
|
996
|
+
b.setAttribute('aria-selected', String(!!o.selected));
|
|
997
|
+
b.appendChild(el('span', 'pick__ck', o.selected ? '✓' : ''));
|
|
998
|
+
var col = el('span');
|
|
999
|
+
col.appendChild(el('span', 'pick__l', o.label));
|
|
1000
|
+
if (o.desc) col.appendChild(el('span', 'pick__d', o.desc));
|
|
1001
|
+
b.appendChild(col);
|
|
1002
|
+
if (o.chev) b.appendChild(el('span', 'pick__chev', '›'));
|
|
1003
|
+
/* keepOpen:一级「引擎」行点进去要切到二级,不能先关抽屉 */
|
|
1004
|
+
b.onclick = function () { if (!o.keepOpen) close(); if (o.onPick) o.onPick(); };
|
|
1005
|
+
bodyEl.appendChild(b);
|
|
1006
|
+
},
|
|
1007
|
+
back: function (label, onBack) {
|
|
1008
|
+
var b = el('button', 'pick__back');
|
|
1009
|
+
b.type = 'button';
|
|
1010
|
+
b.appendChild(el('span', 'pick__back__a', '←'));
|
|
1011
|
+
b.appendChild(el('span', null, label));
|
|
1012
|
+
b.onclick = onBack;
|
|
1013
|
+
bodyEl.appendChild(b);
|
|
1042
1014
|
}
|
|
1043
1015
|
});
|
|
1044
|
-
}
|
|
1016
|
+
}
|
|
1017
|
+
function open(anchor, fn) {
|
|
1018
|
+
/* 同一按钮再点一次 = 收起 */
|
|
1019
|
+
if (panel && curAnchor === anchor) { close(); return; }
|
|
1020
|
+
close();
|
|
1021
|
+
curAnchor = anchor;
|
|
1022
|
+
build();
|
|
1023
|
+
render(fn);
|
|
1024
|
+
}
|
|
1025
|
+
return { open: open, close: close, render: render };
|
|
1026
|
+
})();
|
|
1027
|
+
|
|
1028
|
+
/* 引擎 · 模型:**省市联动**(左列引擎 · 右列该引擎的模型,选中实时联动切换)。
|
|
1029
|
+
—— 不是「下钻二级」那种一列换一列;两列同屏,像省市选择器。 */
|
|
1030
|
+
function openEnginePicker(anchor) {
|
|
1031
|
+
Sheet.open(anchor, function (M) { engineModelCascade(M); });
|
|
1032
|
+
}
|
|
1033
|
+
/* 引擎/模型**落定**(点了具体模型或「默认模型」)时通知宿主。
|
|
1034
|
+
宿主据此把它记成下次新建任务的默认值(移动端「我的 → 默认引擎/模型」);
|
|
1035
|
+
只点左列引擎切右列不算落定 —— 那时模型还没选,记下来会挂到旧引擎上。
|
|
1036
|
+
可选回调:没给就什么都不做(PC 宿主有自己的 localStorage 口径)。 */
|
|
1037
|
+
function notifyPick() {
|
|
1038
|
+
if (!opts.onPick) return;
|
|
1039
|
+
try { opts.onPick(state.engine, state.model); } catch (e) {}
|
|
1040
|
+
}
|
|
1041
|
+
function engineModelCascade(M) {
|
|
1042
|
+
var all = state.engines || [];
|
|
1043
|
+
var available = all.filter(function (x) { return x.ok; });
|
|
1044
|
+
M.title('选择引擎 / 模型(' + available.length + ' / ' + all.length + ')');
|
|
1045
|
+
if (!available.length) {
|
|
1046
|
+
M.row({ label: '没有可用引擎', desc: 'magic-agent --engines 会给每个引擎的一键安装命令' });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
var wrap = el('div', 'pick__cols');
|
|
1050
|
+
var left = el('div', 'pick__col');
|
|
1051
|
+
var right = el('div', 'pick__col pick__col--r');
|
|
1052
|
+
wrap.appendChild(left); wrap.appendChild(right);
|
|
1053
|
+
M.node(wrap);
|
|
1054
|
+
|
|
1055
|
+
var cur = null;
|
|
1056
|
+
available.forEach(function (en) { if (en.engine === state.engine) cur = en; });
|
|
1057
|
+
if (!cur) cur = available[0];
|
|
1058
|
+
|
|
1059
|
+
function chooseEngine(en) {
|
|
1060
|
+
cur = en;
|
|
1061
|
+
var models = en.models || [];
|
|
1062
|
+
state.engine = en.engine;
|
|
1063
|
+
if (models.indexOf(state.model) < 0) state.model = '';
|
|
1064
|
+
if (!engineSupportsPermission(en.engine)) state.permission = '';
|
|
1065
|
+
syncPickers();
|
|
1066
|
+
paint();
|
|
1067
|
+
}
|
|
1068
|
+
function paint() {
|
|
1069
|
+
left.innerHTML = ''; right.innerHTML = '';
|
|
1070
|
+
available.forEach(function (en) {
|
|
1071
|
+
var n = (en.models || []).length;
|
|
1072
|
+
var b = el('button', 'pick__citem' + (en.engine === cur.engine ? ' is-on' : ''));
|
|
1073
|
+
b.type = 'button';
|
|
1074
|
+
b.appendChild(el('span', 'pick__cl', en.engine));
|
|
1075
|
+
b.appendChild(el('span', 'pick__cd', n ? (n + ' 个模型') : '引擎默认'));
|
|
1076
|
+
b.onclick = function () { chooseEngine(en); };
|
|
1077
|
+
left.appendChild(b);
|
|
1078
|
+
});
|
|
1079
|
+
var models = cur.models || [];
|
|
1080
|
+
var d = el('button', 'pick__citem' + (state.engine === cur.engine && !state.model ? ' is-on' : ''));
|
|
1081
|
+
d.type = 'button';
|
|
1082
|
+
d.appendChild(el('span', 'pick__cl', '默认模型'));
|
|
1083
|
+
d.appendChild(el('span', 'pick__cd', '不传 -m,由引擎自己挑'));
|
|
1084
|
+
d.onclick = function () { state.engine = cur.engine; state.model = ''; syncPickers(); notifyPick(); Sheet.close(); };
|
|
1085
|
+
right.appendChild(d);
|
|
1086
|
+
models.forEach(function (mm) {
|
|
1087
|
+
var b = el('button', 'pick__citem' + (state.engine === cur.engine && state.model === mm ? ' is-on' : ''));
|
|
1088
|
+
b.type = 'button';
|
|
1089
|
+
b.appendChild(el('span', 'pick__cl', mm));
|
|
1090
|
+
b.onclick = function () { state.engine = cur.engine; state.model = mm; syncPickers(); notifyPick(); Sheet.close(); };
|
|
1091
|
+
right.appendChild(b);
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
paint();
|
|
1045
1095
|
}
|
|
1046
1096
|
|
|
1047
1097
|
function openPermissionPicker(anchor) {
|
|
1048
|
-
|
|
1049
|
-
|
|
1098
|
+
Sheet.open(anchor, function (M) {
|
|
1099
|
+
M.title('授权档位');
|
|
1050
1100
|
if (!engineSupportsPermission(state.engine)) {
|
|
1051
|
-
|
|
1052
|
-
|
|
1101
|
+
M.row({ label: (state.engine || '当前引擎') + ' 不支持显式选档',
|
|
1102
|
+
desc: '仅 claude / codebuddy / codebuddy-ai,会沿用 core 默认' });
|
|
1053
1103
|
return;
|
|
1054
1104
|
}
|
|
1055
1105
|
PERMISSION_TIERS.forEach(function (t) {
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1106
|
+
M.row({
|
|
1107
|
+
label: t.label, desc: t.hint, danger: t.v === 'full',
|
|
1108
|
+
selected: (state.permission || '') === t.v,
|
|
1109
|
+
onPick: function () { state.permission = t.v; syncPickers(); }
|
|
1110
|
+
});
|
|
1059
1111
|
});
|
|
1060
1112
|
});
|
|
1061
1113
|
}
|
|
1062
1114
|
|
|
1115
|
+
/* ══════════ 续传:按 ask 缓冲补齐(断线重连 / 打开运行中会话共用)══════════
|
|
1116
|
+
⚠️ 游标只能是 `state.lastSeq`(**本轮流 seq**,每轮从 1)。会话日志里的 seq 是
|
|
1117
|
+
文件级跨轮接号的(internal/cli/sessionlog.go 的 openFile 会接着最大 seq 编号),
|
|
1118
|
+
两套口径对不上 —— 拿日志 seq 当游标续拉会错位(多补一整轮或一条都不补)。
|
|
1119
|
+
|
|
1120
|
+
h.onEnd(reason, code, incomplete) 只回调一次,reason ∈
|
|
1121
|
+
done 服务端说这轮结束了(终态事件已在本次 events 里画过)
|
|
1122
|
+
closed 补拉期间终态已由别处落定(state.qaClosed)
|
|
1123
|
+
missing 404 / 连续失败:服务端没保留这轮,补不到了
|
|
1124
|
+
unreachable 连续 40 次网络失败
|
|
1125
|
+
timeout 10 分钟还没等到终态
|
|
1126
|
+
stopped 被取消(换会话 / 开新轮)—— 调用方只需收摊,不要报错 */
|
|
1127
|
+
function tailAsk(runID, h) {
|
|
1128
|
+
h = h || {};
|
|
1129
|
+
var tries = 0, backoff = 600, incomplete = false, stopped = false, timer = 0;
|
|
1130
|
+
var deadline = Date.now() + 10 * 60 * 1000;
|
|
1131
|
+
var cancel = function () { stop('stopped', null); };
|
|
1132
|
+
var stop = function (reason, code) {
|
|
1133
|
+
if (stopped) return;
|
|
1134
|
+
stopped = true;
|
|
1135
|
+
if (timer) { clearTimeout(timer); timer = 0; }
|
|
1136
|
+
/* 只清自己挂上去的钩子:新轮可能已经把自己的 cancel 换进来了,别误清。 */
|
|
1137
|
+
if (state.resumeStop === cancel) state.resumeStop = null;
|
|
1138
|
+
try { if (h.onEnd) h.onEnd(reason || 'stopped', code, incomplete); } catch (e) {}
|
|
1139
|
+
};
|
|
1140
|
+
state.resumeStop = cancel;
|
|
1141
|
+
var poll = function () {
|
|
1142
|
+
if (stopped) return;
|
|
1143
|
+
if (state.qaClosed) return stop('closed', null);
|
|
1144
|
+
if (Date.now() > deadline) return stop('timeout', null);
|
|
1145
|
+
var p;
|
|
1146
|
+
try { p = transport.askEvents(runID, state.lastSeq || 0); }
|
|
1147
|
+
catch (e) { p = Promise.reject(e); }
|
|
1148
|
+
Promise.resolve(p).then(function (res) {
|
|
1149
|
+
tries = 0;
|
|
1150
|
+
if (!res || !Array.isArray(res.events)) throw new Error('events 载荷不合法');
|
|
1151
|
+
/* 游标落在服务端裁剪窗口之前:中间事件已丢,补出来的是残卷。 */
|
|
1152
|
+
if (res.snapshotRequired) incomplete = true;
|
|
1153
|
+
res.events.forEach(function (ev) {
|
|
1154
|
+
try {
|
|
1155
|
+
/* 宿主已经用别处的来源画过的事件(如日志里重放过的用户提问)在这里跳过,
|
|
1156
|
+
见 attachRunningAsk 的 alreadyRenderedUser。 */
|
|
1157
|
+
if (h.skipEvent && h.skipEvent(ev)) return;
|
|
1158
|
+
render(ev);
|
|
1159
|
+
} catch (e) {}
|
|
1160
|
+
});
|
|
1161
|
+
if (stopped) return;
|
|
1162
|
+
if (state.qaClosed) return stop('closed', null);
|
|
1163
|
+
if (res.done) return stop('done', res.code);
|
|
1164
|
+
timer = setTimeout(poll, 1000); // 还在跑:继续等增量
|
|
1165
|
+
}).catch(function (e) {
|
|
1166
|
+
if (stopped) return;
|
|
1167
|
+
/* 404 = 服务端没保留这轮(已过保留期,或网关还是旧版没这个接口)——
|
|
1168
|
+
再重试也不会有,直接收尾,别让用户干等几分钟。 */
|
|
1169
|
+
if (e && e.status === 404) return stop('missing', null);
|
|
1170
|
+
tries++;
|
|
1171
|
+
if (tries > 40) return stop('unreachable', null);
|
|
1172
|
+
backoff = Math.min(8000, backoff * 2);
|
|
1173
|
+
timer = setTimeout(poll, backoff);
|
|
1174
|
+
});
|
|
1175
|
+
};
|
|
1176
|
+
poll();
|
|
1177
|
+
return { stop: function () { stop('stopped', null); } };
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/* 本进程里挂在 `sid` 上的运行中轮次(多条取 started 最晚的那条)。
|
|
1181
|
+
服务端没给 session(旧版 / 新会话尚未拿到 session_id)→ 认不出来,返回 null。 */
|
|
1182
|
+
function runningAskOf(asks, sid) {
|
|
1183
|
+
if (!Array.isArray(asks) || !sid) return null;
|
|
1184
|
+
var best = null, bestT = -Infinity;
|
|
1185
|
+
asks.forEach(function (a) {
|
|
1186
|
+
if (!a || !a.id) return;
|
|
1187
|
+
/* 会话 id 可能是 session_id,也可能是 run_id(引擎整轮不吐 session_id 时
|
|
1188
|
+
登记表里只有 run_id,客户端列表也用 run_id 当 id,见 mobile 的 sidOf)。
|
|
1189
|
+
a.session 与 a.run 都要比。 */
|
|
1190
|
+
if (a.session !== sid && a.run !== sid) return;
|
|
1191
|
+
var t = Date.parse(String(a.started || ''));
|
|
1192
|
+
if (!isFinite(t)) t = 0;
|
|
1193
|
+
if (!best || t >= bestT) { best = a; bestT = t; }
|
|
1194
|
+
});
|
|
1195
|
+
return best;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/* 日志里最后一轮的用户提问在哪、有没有收尾。
|
|
1199
|
+
打开一条**还在跑**的会话时,这一轮「提问之后」的事件不能从日志重放:
|
|
1200
|
+
日志可能还没 flush(1s 一批)、且它的 seq 与 ask 缓冲不同口径 ——
|
|
1201
|
+
只重放到这条 user 为止,其余交给 ask 缓冲从 0 补齐。 */
|
|
1202
|
+
function lastTurnInfo(events) {
|
|
1203
|
+
var idx = -1;
|
|
1204
|
+
for (var i = 0; i < events.length; i++) {
|
|
1205
|
+
var k = events[i] && (events[i].kind || events[i].type);
|
|
1206
|
+
if (k === 'user') idx = i;
|
|
1207
|
+
}
|
|
1208
|
+
var terminal = false;
|
|
1209
|
+
for (var j = idx + 1; j < events.length; j++) {
|
|
1210
|
+
var k2 = events[j] && (events[j].kind || events[j].type);
|
|
1211
|
+
if (k2 === 'turn_end' || k2 === 'turn_failed' || k2 === 'result' || k2 === 'error' || k2 === 'end') { terminal = true; break; }
|
|
1212
|
+
}
|
|
1213
|
+
return { index: idx, terminal: terminal, at: idx >= 0 ? replayAt(events[idx]) : 0 };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/* 补拉路径的收口:内核的统一收尾(qaRunEnd = finish + 摘「生成中」胶囊)之后
|
|
1217
|
+
再补收尾行。为什么不能只调 kernelDone:内核的 `result` 分支**不收尾**
|
|
1218
|
+
(实时流靠网关的 SSE `end` 帧收尾),而 ask 缓冲里只有 core 的 stdout 行 ——
|
|
1219
|
+
llm 这类整轮只吐 `result` 的引擎补到最后也等不到 end 帧,
|
|
1220
|
+
胶囊会永久钉在画布上(2026-10-20 真机实测「llm · 生成中」与「已完成」同屏)。 */
|
|
1221
|
+
function closeAttached(ok, label) {
|
|
1222
|
+
var q = state.qa;
|
|
1223
|
+
if (!q || !q.h) return;
|
|
1224
|
+
if (turnArts) { try { turnArts.flush(q.h); } catch (e0) {} } // 补挂产物要在拆卡之前
|
|
1225
|
+
try {
|
|
1226
|
+
if (typeof qaRunEnd === 'function') qaRunEnd(q.h);
|
|
1227
|
+
else if (typeof q.h.finish === 'function') q.h.finish();
|
|
1228
|
+
} catch (e) { /* 封口失败不影响收尾行 */ }
|
|
1229
|
+
kernelDone(ok, label);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/* 把画面接回一条**还在跑**的轮次(2026-10-20 用户:「切回来还是没好」)。
|
|
1233
|
+
打开会话时若发现同会话的轮次仍在跑:历史只回放到那条 user,这里从 ask 缓冲
|
|
1234
|
+
`after=0` 补齐并持续增量补拉,直到终态;同时置 busy(拦住误发新消息)。
|
|
1235
|
+
|
|
1236
|
+
alreadyRenderedUser:那条 user 已经由日志重放画过了(loadHistory 的 partial 分支)。
|
|
1237
|
+
网关从 2026-10-20 起会在缓冲头合成一条 `type:"user"`(core 的事件流里没有这条),
|
|
1238
|
+
不跳过它这一轮就会出现两个用户气泡。旧网关没有这条合成事件 —— 跳过逻辑等不到它,
|
|
1239
|
+
什么也不影响。 */
|
|
1240
|
+
function attachRunningAsk(run, t0, alreadyRenderedUser) {
|
|
1241
|
+
if (!run || !run.id || typeof transport.askEvents !== 'function') return false;
|
|
1242
|
+
if (state.busy && state.askId === run.id) return false; // 已经在看这条流了
|
|
1243
|
+
var runID = run.id;
|
|
1244
|
+
var pendingUser = !!alreadyRenderedUser;
|
|
1245
|
+
state.askId = runID;
|
|
1246
|
+
state.busy = true;
|
|
1247
|
+
setSendMode('busy');
|
|
1248
|
+
state.qaClosed = false;
|
|
1249
|
+
state.renderedSeq = {}; // 每轮从 1:这是新的一轮,别拿上一轮的 seq 判重
|
|
1250
|
+
state.lastSeq = 0; // 缓冲里这一轮的事件从头补
|
|
1251
|
+
state.resumeT0 = t0 || 0; // 收尾耗时从日志里那条 user 的 `at` 起算
|
|
1252
|
+
setWorking();
|
|
1253
|
+
if (opts.onBusy) { try { opts.onBusy(true); } catch (e) {} }
|
|
1254
|
+
tailAsk(runID, {
|
|
1255
|
+
skipEvent: function (ev) {
|
|
1256
|
+
if (!pendingUser || !ev || (ev.type || ev.kind) !== 'user') return false;
|
|
1257
|
+
pendingUser = false;
|
|
1258
|
+
return true;
|
|
1259
|
+
},
|
|
1260
|
+
onEnd: function (reason, code, incomplete) {
|
|
1261
|
+
/* 已被新一轮接管(用户在这期间发了消息):这一轮的收摊不归这里管。 */
|
|
1262
|
+
if (state.askId && state.askId !== runID) return;
|
|
1263
|
+
clearWorking();
|
|
1264
|
+
state.busy = false;
|
|
1265
|
+
state.askId = null;
|
|
1266
|
+
state.resumeT0 = 0;
|
|
1267
|
+
setSendMode('idle');
|
|
1268
|
+
if (opts.onBusy) { try { opts.onBusy(false); } catch (e) {} }
|
|
1269
|
+
if (reason === 'stopped' || reason === 'closed') return;
|
|
1270
|
+
if (reason === 'done') {
|
|
1271
|
+
/* 补到终态的引擎(turn_end / error)已由内核自己封口;llm 这类只有 result
|
|
1272
|
+
的引擎在这里兜底,否则底部会永远留着「生成中」胶囊和「Agent 运行中」。 */
|
|
1273
|
+
if (!state.qaClosed && state.qa && state.qa.h) {
|
|
1274
|
+
closeAttached(code === 0 || code == null, '已完成');
|
|
1275
|
+
}
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
/* 补不到了:如实说,并把还挂着的那一笔封口(否则「生成中」一直转)。 */
|
|
1279
|
+
if (reason === 'timeout') noticeItem('补拉超时,回复可能不完整,请刷新会话');
|
|
1280
|
+
else if (reason === 'unreachable') noticeItem('连接中断且无法续传,请刷新会话');
|
|
1281
|
+
else noticeItem('无法续传本轮事件(服务端未保留),回复可能不完整');
|
|
1282
|
+
if (!state.qaClosed && state.qa && state.qa.h) {
|
|
1283
|
+
closeAttached(false, '连接中断 · 回复可能不完整');
|
|
1284
|
+
} else if (incomplete) {
|
|
1285
|
+
noticeItem('部分过程事件已丢失,回复可能不完整');
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
return true;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/* 相邻 thinking 分片并成一条(只并相邻;用数组攒再 join,别用 text += 逐片拼)。 */
|
|
1293
|
+
function mergeThinkDeltas(list) {
|
|
1294
|
+
var out = [];
|
|
1295
|
+
for (var i = 0; i < list.length; i++) {
|
|
1296
|
+
var ev = list[i];
|
|
1297
|
+
if (ev && ev.kind === 'thinking' && list[i + 1] && list[i + 1].kind === 'thinking') {
|
|
1298
|
+
var parts = [String(ev.text == null ? '' : ev.text)];
|
|
1299
|
+
var j = i + 1;
|
|
1300
|
+
while (j < list.length && list[j] && list[j].kind === 'thinking') {
|
|
1301
|
+
parts.push(String(list[j].text == null ? '' : list[j].text));
|
|
1302
|
+
j++;
|
|
1303
|
+
}
|
|
1304
|
+
var merged = {};
|
|
1305
|
+
for (var k in ev) { if (Object.prototype.hasOwnProperty.call(ev, k)) merged[k] = ev[k]; }
|
|
1306
|
+
merged.text = parts.join('');
|
|
1307
|
+
out.push(merged);
|
|
1308
|
+
i = j - 1;
|
|
1309
|
+
} else {
|
|
1310
|
+
out.push(ev);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
return out;
|
|
1314
|
+
}
|
|
1315
|
+
/* ── 历史分页(2026-10-20 用户:「默认加载最近的一轮用户和回复即可」「滚一下至少也要
|
|
1316
|
+
补一轮回话 不是只补几个子过程」)──
|
|
1317
|
+
服务端 `?before=N&limit=M` 反向分页:N = 从文件尾跳过多少条(不是 seq),并保证一页
|
|
1318
|
+
至少含一个轮次起点(user,见 internal/session/sessionlog.go 的 alignTo)。这里首屏再
|
|
1319
|
+
**自己裁到最后一个 user**,于是「默认只画最近一轮」不依赖 limit 恰好等于一轮长度。
|
|
1320
|
+
相邻页都从 user 起头 ⇒ 前插不会把同一轮切成两半(PC 那边的 qaConvJoinTurn 因此不需要)。
|
|
1321
|
+
旧服务端不认识 before → 返回全量且**没有 more 字段**,据此退回整份回放、不出入口。 */
|
|
1322
|
+
var HIST_PAGE = 200; // 一页事件数(服务端还会往前凑到轮次起点)
|
|
1323
|
+
var HIST_MAX_PAGES = 6; // 超长单轮(一页全是思考/工具):首屏最多往回捞几页
|
|
1324
|
+
|
|
1325
|
+
function histHasMore(r) { return !!r && Object.prototype.hasOwnProperty.call(r, 'more'); }
|
|
1326
|
+
function readHistPage(sid, before) {
|
|
1327
|
+
return Promise.resolve(transport.readSession(sid, { before: before, limit: HIST_PAGE }));
|
|
1328
|
+
}
|
|
1329
|
+
/** 一串事件里最后一条用户提问的下标(没有回 -1)。 */
|
|
1330
|
+
function lastUserIdx(list) {
|
|
1331
|
+
for (var i = list.length - 1; i >= 0; i--) {
|
|
1332
|
+
var k = list[i] && (list[i].kind || list[i].type);
|
|
1333
|
+
if (k === 'user') return i;
|
|
1334
|
+
}
|
|
1335
|
+
return -1;
|
|
1336
|
+
}
|
|
1337
|
+
/* 首屏:从文件尾取一页。页内找不到 user 说明这一轮长到一页装不下(页上限被 maxTurnPage
|
|
1338
|
+
截断)—— 继续往前捞,最多 HIST_MAX_PAGES 页;仍没有就整页显示(入口照留)。 */
|
|
1339
|
+
function histFirstPage(sid) {
|
|
1340
|
+
var older = [], pages = [], before = 0, tried = 0;
|
|
1341
|
+
function step() {
|
|
1342
|
+
return readHistPage(sid, before).then(function (r) {
|
|
1343
|
+
if (!histHasMore(r)) return { events: (r && r.events) || [], legacy: true };
|
|
1344
|
+
var evs = (r && r.events) || [];
|
|
1345
|
+
tried++;
|
|
1346
|
+
/* pages 按「旧 → 新」排列:页首是该页最老的一条,nextBefore 是它的绝对游标 +1。 */
|
|
1347
|
+
pages.unshift({ nextBefore: Number(r.nextBefore) || 0, len: evs.length, more: !!r.more });
|
|
1348
|
+
older = evs.concat(older);
|
|
1349
|
+
var keep = lastUserIdx(older);
|
|
1350
|
+
if (keep >= 0 || !r.more || tried >= HIST_MAX_PAGES) {
|
|
1351
|
+
return { events: older, pages: pages, keep: keep };
|
|
1352
|
+
}
|
|
1353
|
+
before = Number(r.nextBefore) || 0;
|
|
1354
|
+
if (!before) return { events: older, pages: pages, keep: keep };
|
|
1355
|
+
return step();
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
return step();
|
|
1359
|
+
}
|
|
1360
|
+
/* 把「从 keep 起才画」翻译成下一页的游标:
|
|
1361
|
+
before = 页首绝对游标 − 页内偏移(页内相邻事件的游标严格递减 1,见 sessionlog.go 的 dist)。 */
|
|
1362
|
+
function histCursor(r, keep) {
|
|
1363
|
+
var pages = r.pages || [];
|
|
1364
|
+
var acc = 0;
|
|
1365
|
+
for (var i = 0; i < pages.length; i++) {
|
|
1366
|
+
var pg = pages[i];
|
|
1367
|
+
if (keep < acc + pg.len) {
|
|
1368
|
+
return { before: Math.max(0, pg.nextBefore - (keep - acc)), more: i > 0 || keep > acc || pg.more };
|
|
1369
|
+
}
|
|
1370
|
+
acc += pg.len;
|
|
1371
|
+
}
|
|
1372
|
+
return { before: 0, more: false };
|
|
1373
|
+
}
|
|
1374
|
+
/* ── 读历史时的页面 loading(2026-10-20 用户:「要页面 loading 的水平垂直居中的那种效果」
|
|
1375
|
+
→「要撑满宽高」→「要更有创意 使用掌天瓶填充绿液作为加载效果」)──
|
|
1376
|
+
从点开一条会话到第一屏真正画出来之间,是**纯等网络**的空窗:反向分页要往返 1~3 次,
|
|
1377
|
+
超大日志还要多捞几页(HIST_MAX_PAGES)。此前这段时间画布是空的,用户只看到顶栏标题
|
|
1378
|
+
变了、下面一片白,分不清「正在读」「这条会话是空的」「加载挂了」。
|
|
1379
|
+
形态:**整块画布居中的一枚掌天瓶,绿液缓缓涨起来**(图形见上面的 bottleSVG)。
|
|
1380
|
+
为什么是瓶而不是转环/骨架:这个产品就叫掌天瓶,「灌绿液」既是品牌动作,也真的读得出
|
|
1381
|
+
「在往里装东西」—— 比一枚通用转环多一层「装的是这条会话的历史」的意思。
|
|
1382
|
+
绿液**不确定进度**(不知道还要读几页),所以是无限循环的涨落,涨满→回落,不假装百分比。
|
|
1383
|
+
只在**首载**(after == null)出现,且三种情况都会撤:
|
|
1384
|
+
· 第一条事件真画出来(render 包装里 endHistLoading(false));
|
|
1385
|
+
· 读完是空会话 / 读失败(loadHistory 收尾 endHistLoading(true) → 空态那句还回去)。
|
|
1386
|
+
140ms 内就回来的(本地小会话)根本不亮,避免「刚出现就消失」的抖动。
|
|
1387
|
+
⚠️ 动画只动 transform / opacity(见 conversation.css):回放大日志会占死主线程,
|
|
1388
|
+
动 layout 的属性会让这一屏跟着冻住,看上去就像「卡在读取」。 */
|
|
1389
|
+
var histLoading = null; // loading 节点(懒建:没读过历史就不建)
|
|
1390
|
+
var histLoadingWant = false; // 这一轮首载还没结束
|
|
1391
|
+
var histLoadingTimer = 0; // 「140ms 还没回来才显示」的定时器
|
|
1392
|
+
function histLoadingNode() {
|
|
1393
|
+
if (histLoading) return histLoading;
|
|
1394
|
+
var n = el('div', 'conv__loading');
|
|
1395
|
+
n.setAttribute('role', 'status');
|
|
1396
|
+
n.setAttribute('aria-live', 'polite');
|
|
1397
|
+
n.setAttribute('aria-busy', 'true');
|
|
1398
|
+
var stage = el('div', 'conv__loading__bottle');
|
|
1399
|
+
/* fill .55:液面停在腹部偏上 —— 不空(看得出是绿液)也不满(满瓶就该收工了)。
|
|
1400
|
+
涨落由 CSS 的 qbRise 叠在上面(±8px),见 conversation.css。 */
|
|
1401
|
+
stage.appendChild(bottleSVG({ fill: 0.68, anim: 'hist' }));
|
|
1402
|
+
n.appendChild(stage);
|
|
1403
|
+
n.appendChild(el('div', 'conv__loading__label', '正在读取会话历史…'));
|
|
1404
|
+
histLoading = n;
|
|
1405
|
+
return n;
|
|
1406
|
+
}
|
|
1407
|
+
function showHistLoading() {
|
|
1408
|
+
var n = histLoadingNode();
|
|
1409
|
+
/* 空态那句「还没有事件」先让位:两条同时出现会被读成「历史就是空的,还读什么」。 */
|
|
1410
|
+
if (empty && empty.parentNode) empty.parentNode.removeChild(empty);
|
|
1411
|
+
/* 常驻第二个孩子(紧跟「加载更早」入口)—— 内容一律往画布尾追加,它自然在最上面。 */
|
|
1412
|
+
if (!n.parentNode) threadEl.insertBefore(n, moreBtn.nextSibling || null);
|
|
1413
|
+
}
|
|
1414
|
+
function endHistLoading(restoreEmpty) {
|
|
1415
|
+
histLoadingWant = false;
|
|
1416
|
+
if (histLoadingTimer) { clearTimeout(histLoadingTimer); histLoadingTimer = 0; }
|
|
1417
|
+
if (histLoading && histLoading.parentNode) histLoading.parentNode.removeChild(histLoading);
|
|
1418
|
+
/* 一条事件都没画出来(空会话 / 读失败)→ 把空态还回去;有内容时别塞(会被当成新会话引导)。 */
|
|
1419
|
+
if (restoreEmpty && empty && !empty.parentNode &&
|
|
1420
|
+
!threadEl.querySelector('.qa-msg, .qa-hist-note')) threadEl.appendChild(empty);
|
|
1421
|
+
}
|
|
1422
|
+
function beginHistLoading() {
|
|
1423
|
+
histLoadingWant = true;
|
|
1424
|
+
if (histLoadingTimer) return; // 已在计时:别把上一拍的延时越攒越长
|
|
1425
|
+
histLoadingTimer = setTimeout(function () {
|
|
1426
|
+
histLoadingTimer = 0;
|
|
1427
|
+
if (histLoadingWant) showHistLoading();
|
|
1428
|
+
}, 140);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1063
1431
|
function loadHistory(sid, after) {
|
|
1064
1432
|
if (!sid) return Promise.resolve(0);
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1433
|
+
if (after == null) beginHistLoading();
|
|
1434
|
+
/* 打开会话时**并行**问两件事:日志 + 本进程有没有挂在同一会话上的运行中轮次。
|
|
1435
|
+
后者决定历史回放到哪(见 lastTurnInfo);拉不到就当没有 ——
|
|
1436
|
+
旧网关没有 /desk/asks,不能因为它让历史打不开。 */
|
|
1437
|
+
var asksP = (after == null && typeof transport.listAsks === 'function')
|
|
1438
|
+
? Promise.resolve().then(function () { return transport.listAsks(); }).catch(function () { return []; })
|
|
1439
|
+
: Promise.resolve([]);
|
|
1440
|
+
/* after != null 是增量续读(老调用方),维持原样;首载走反向分页。 */
|
|
1441
|
+
var firstP = after == null
|
|
1442
|
+
? histFirstPage(sid)
|
|
1443
|
+
: Promise.resolve(transport.readSession(sid, { after: after }));
|
|
1444
|
+
var done = Promise.all([firstP, asksP]).then(function (rs) {
|
|
1445
|
+
var r = rs[0];
|
|
1446
|
+
var events = (r && r.events) || [];
|
|
1447
|
+
/* ⚠️ **不按 seq 排序**(2026-10-16):日志是跨轮追加的,老日志里每轮的 seq
|
|
1448
|
+
都从 1 重来(每轮一个新 core 进程),按 seq 排会把各轮事件**交错**在一起
|
|
1449
|
+
(所有 seq=1 的排前面…)。文件本身的顺序就是时间顺序 —— 直接照它渲染。
|
|
1450
|
+
新核心已把 seq 改成跨轮接号(见 cli/sessionlog.go 的 openFile),
|
|
1451
|
+
所以这里排不排都不影响新数据。 */
|
|
1452
|
+
/* 首载只画最近一轮:裁到页面里最后一个 user。没有 user(日志本来就没有提问)
|
|
1453
|
+
或旧服务端 → 原样全画。 */
|
|
1454
|
+
var hist = null;
|
|
1455
|
+
if (after == null && r && !r.legacy) {
|
|
1456
|
+
var keep = typeof r.keep === 'number' ? r.keep : lastUserIdx(events);
|
|
1457
|
+
if (keep > 0) {
|
|
1458
|
+
events = events.slice(keep);
|
|
1459
|
+
} else if (keep < 0) {
|
|
1460
|
+
keep = 0;
|
|
1461
|
+
}
|
|
1462
|
+
hist = histCursor(r, keep);
|
|
1463
|
+
}
|
|
1464
|
+
/* ⚠️ 回放标记(2026-10-12):回放**不走 seq 去重**(见 render 开头那段)——
|
|
1465
|
+
日志是跨轮追加的,老日志里每轮的 seq 都从 1 重来,去重会把第二轮起的事件当成
|
|
1466
|
+
重复帧全丢掉。打了这个标,render 那边就跳过 seq 判重。 */
|
|
1467
|
+
var run = runningAskOf(rs[1], sid);
|
|
1468
|
+
var info = lastTurnInfo(events);
|
|
1469
|
+
/* 有运行中的同会话轮次、且日志里那一轮还没收尾 → 只重放到那条 user(含),
|
|
1470
|
+
提问之后的助手侧内容一律交给 ask 缓冲补齐(日志可能落后 / seq 口径不同)。 */
|
|
1471
|
+
var partial = !!(run && info.index >= 0 && !info.terminal);
|
|
1472
|
+
state.replay = true;
|
|
1473
|
+
state.replayT0 = 0; state.replayLastAt = 0;
|
|
1474
|
+
try {
|
|
1475
|
+
var replayList = events.slice(0, partial ? info.index + 1 : events.length);
|
|
1476
|
+
/* 相邻思考增量先并成一段再喂(2026-10-20 用户:「移动端加载也很慢」):
|
|
1477
|
+
日志是按流式增量落盘的,一条长会话实测 95838 条事件里 94015 条是
|
|
1478
|
+
~3 字符的 thinking 分片 —— 逐条 render 要 16s、主线程整段占死。
|
|
1479
|
+
思考区本来就是「一段连续 thinking 一块」(见 03-chat-message),
|
|
1480
|
+
合并后画出来的内容完全相同。服务端 `?lean=1` 已做过同一刀,
|
|
1481
|
+
这里兜底旧服务端(它不认识 lean)。
|
|
1482
|
+
⚠️ 只并**相邻**的,中间夹了别的 kind 就断开。 */
|
|
1483
|
+
replayList = mergeThinkDeltas(replayList);
|
|
1484
|
+
replayList.forEach(function (ev) {
|
|
1485
|
+
try { render(ev); } catch (e) { /* 单条坏事件吞掉 */ }
|
|
1486
|
+
});
|
|
1487
|
+
/* 日志尾部没有终态事件(那一轮被中断)→ 把最后一轮也封口,
|
|
1488
|
+
否则底部会永远留着一枚「生成中」胶囊。
|
|
1489
|
+
续传中的那一轮**不封**:它的收尾由 ask 缓冲的终态事件落定。 */
|
|
1490
|
+
if (!partial) finishReplayRound();
|
|
1491
|
+
} finally { state.replay = false; }
|
|
1492
|
+
var t = thread(); t.scrollTop = t.scrollHeight;
|
|
1493
|
+
/* 打开 / 切换会话 = 从底部开始看:把内核的「用户离开底部」闸复位(见 qaScrollReset)。 */
|
|
1494
|
+
if (typeof qaScrollReset === 'function') { try { qaScrollReset(); } catch (e) {} }
|
|
1495
|
+
state.autoFollow = true;
|
|
1496
|
+
/* 分页游标落定后才显示入口(旧服务端 hist = null → 不显示,行为与改动前一致)。 */
|
|
1497
|
+
state.hist = after == null ? hist : state.hist;
|
|
1498
|
+
syncMore();
|
|
1499
|
+
/* partial 时那条 user 已由上面的日志重放画过 → 让 attach 跳过网关合成的同一条。 */
|
|
1500
|
+
if (run) attachRunningAsk(run, info.at, partial);
|
|
1501
|
+
/* 首载结束:骨架屏撤下。一条都没画出来(空会话)→ 把空态那句还回去。
|
|
1502
|
+
⚠️ 期间用户可能已经切走(迟到的这一份不许撤新会话的骨架屏)。 */
|
|
1503
|
+
if (after == null && sid === state.session) endHistLoading(true);
|
|
1504
|
+
return events.length;
|
|
1505
|
+
});
|
|
1506
|
+
/* 读失败也要撤骨架屏(否则它会一直挂着,看着像永远读不完)。 */
|
|
1507
|
+
return done.catch(function (e) {
|
|
1508
|
+
if (after == null && sid === state.session) endHistLoading(true);
|
|
1509
|
+
throw e;
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** 顶上「加载更早」入口的显隐与文案(入口本身是画布的第一个孩子,见 clearThread)。 */
|
|
1514
|
+
function syncMore() {
|
|
1515
|
+
if (!moreBtn) return;
|
|
1516
|
+
var h = state.hist;
|
|
1517
|
+
var show = !!(h && (h.more || h.loading));
|
|
1518
|
+
moreBtn.hidden = !show;
|
|
1519
|
+
if (!show) return;
|
|
1520
|
+
moreBtn.textContent = h.loading ? '正在加载更早的消息…' : '向上滚动加载更早的消息';
|
|
1521
|
+
moreBtn.classList.toggle('conv__more--wait', !!h.loading);
|
|
1522
|
+
if (!moreBtn.parentNode) threadEl.insertBefore(moreBtn, threadEl.firstChild);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/* 前插一页(PC 宿主同款做法,见 magic-test/src/js/27-conv-log.js 的 qaConvPrepend):
|
|
1526
|
+
① 现有内容整体摘进 fragment(顺序不变);② 用同一套 render 把这一页画进空画布;
|
|
1527
|
+
③ 旧内容接回去;④ scrollTop += 新增高度 —— 用户正看着的那一行因此停在原地。 */
|
|
1528
|
+
function prependPage(evs) {
|
|
1529
|
+
var t = thread();
|
|
1530
|
+
var prevTop = t.scrollTop, prevH = t.scrollHeight;
|
|
1531
|
+
var frag = document.createDocumentFragment();
|
|
1532
|
+
Array.prototype.slice.call(t.childNodes).forEach(function (n) {
|
|
1533
|
+
if (n !== moreBtn) frag.appendChild(n);
|
|
1534
|
+
});
|
|
1535
|
+
state.replay = true;
|
|
1536
|
+
state.replayT0 = 0; state.replayLastAt = 0;
|
|
1537
|
+
state.qaClosed = false; // 历史轮次要先能开新的一笔
|
|
1538
|
+
try {
|
|
1539
|
+
mergeThinkDeltas(evs).forEach(function (ev) {
|
|
1540
|
+
try { render(ev); } catch (e) { /* 单条坏事件吞掉 */ }
|
|
1072
1541
|
});
|
|
1542
|
+
/* 页尾通常切在同一轮中间(页首才是 user,见 histCursor)→ 封口,
|
|
1543
|
+
否则最后一笔会一直挂着「生成中」胶囊;整轮完整时这里已是空操作。 */
|
|
1544
|
+
finishReplayRound();
|
|
1545
|
+
} finally { state.replay = false; }
|
|
1546
|
+
t.appendChild(frag);
|
|
1547
|
+
/* 别让内核把这个动作当成「有新内容」把画面拽回底部(qaUserAway 是它的滚动闸)。 */
|
|
1548
|
+
if (typeof qaUserAway !== 'undefined') { try { qaUserAway = true; } catch (e) {} }
|
|
1549
|
+
state.autoFollow = false;
|
|
1550
|
+
t.scrollTop = prevTop + (t.scrollHeight - prevH);
|
|
1551
|
+
syncBottomBtn();
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/** 上翻一页(服务端保证页首是本轮 user ⇒ 「滚一下至少补一轮回话」)。 */
|
|
1555
|
+
function loadMore() {
|
|
1556
|
+
var h = state.hist;
|
|
1557
|
+
var sid = state.session;
|
|
1558
|
+
if (!sid || !h || h.loading || !h.more) return Promise.resolve(false);
|
|
1559
|
+
/* 正在看一条运行中的轮次:前插会打乱实时事件的落笔位置,等它收尾再翻。 */
|
|
1560
|
+
if (state.busy) return Promise.resolve(false);
|
|
1561
|
+
h.loading = true;
|
|
1562
|
+
syncMore();
|
|
1563
|
+
/* 一页装不下一整轮时(实测一条会话有 5000 条思考/工具的分页上限),服务端只能给出
|
|
1564
|
+
从轮中间起头的一页 —— 这时继续往前要,直到页首落在 user 上再一次性前插:
|
|
1565
|
+
否则滚一下只多出半截子过程,正是用户说的「看着像滚了没反应」。 */
|
|
1566
|
+
var acc = [], cursor = h.before, more = h.more, pages = 0;
|
|
1567
|
+
function step() {
|
|
1568
|
+
return readHistPage(sid, cursor).then(function (r) {
|
|
1569
|
+
if (sid !== state.session || state.hist !== h) return { stop: true };
|
|
1570
|
+
if (!histHasMore(r)) { more = false; return { stop: true }; } // 服务端退回旧版(正常不该发生)
|
|
1571
|
+
var evs = (r && r.events) || [];
|
|
1572
|
+
if (!evs.length) { more = false; return { stop: true }; }
|
|
1573
|
+
acc = evs.concat(acc);
|
|
1574
|
+
pages++;
|
|
1575
|
+
cursor = Number(r.nextBefore) || 0;
|
|
1576
|
+
more = !!r.more;
|
|
1577
|
+
var head = acc[0] && (acc[0].kind || acc[0].type);
|
|
1578
|
+
if (head === 'user' || !more || !cursor || pages >= HIST_MAX_PAGES) return { stop: true };
|
|
1579
|
+
return step();
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
return step().then(function (res) {
|
|
1583
|
+
if (sid !== state.session || state.hist !== h) return false;
|
|
1584
|
+
if (!acc.length) { h.more = more; return false; }
|
|
1585
|
+
prependPage(acc);
|
|
1586
|
+
h.before = cursor;
|
|
1587
|
+
h.more = more;
|
|
1588
|
+
return true;
|
|
1589
|
+
}).catch(function (e) {
|
|
1590
|
+
noticeItem('加载更早的消息失败:' + (e && e.message ? e.message : e));
|
|
1591
|
+
return false;
|
|
1592
|
+
}).then(function (ok) {
|
|
1593
|
+
h.loading = false;
|
|
1594
|
+
if (state.hist === h) syncMore();
|
|
1595
|
+
return ok;
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/* ══════════ 停留期发现新轮次(轻量版)══════════
|
|
1600
|
+
详情页打开时只 loadHistory 一次(一次性快照)。如果这时 PC 端(或另一个窗口)
|
|
1601
|
+
在**同一个会话**里另起了一轮,本页没有任何触发,画面就停在上轮、只能退出重进
|
|
1602
|
+
(2026-10-20 报障:「pc端新的追问 在移动端是不显示的」)。
|
|
1603
|
+
|
|
1604
|
+
这里每 5s 问一次本进程的运行中轮次(/desk/asks,只比 session / run,不拉事件):
|
|
1605
|
+
发现同会话有 run、而本页没接上它 → 清画布重跑 loadHistory(它回放日志到那条
|
|
1606
|
+
user,再由 attachRunningAsk 接 ask 缓冲继续流)。
|
|
1607
|
+
- 后台页不跑:省电,也避免用户切回来时和 setSession 的那次加载打架。
|
|
1608
|
+
- busy 一律不打扰:本页正在看一轮(本地发的 / 已 attach 的)时,收尾归它自己管。
|
|
1609
|
+
- 网关旧版没有 /desk/asks → 静默,行为与改动前完全一致。 */
|
|
1610
|
+
var WATCH_MS = 5000;
|
|
1611
|
+
var watchTimer = 0, watchInflight = false, watchSeen = '';
|
|
1612
|
+
function stopWatch() {
|
|
1613
|
+
if (watchTimer) { clearInterval(watchTimer); watchTimer = 0; }
|
|
1614
|
+
watchSeen = '';
|
|
1615
|
+
watchInflight = false;
|
|
1616
|
+
}
|
|
1617
|
+
function watchTick() {
|
|
1618
|
+
var sid = state.session;
|
|
1619
|
+
if (!sid || state.busy || watchInflight) return;
|
|
1620
|
+
if (typeof document !== 'undefined' && document.visibilityState && document.visibilityState !== 'visible') return;
|
|
1621
|
+
if (typeof transport.listAsks !== 'function') return;
|
|
1622
|
+
watchInflight = true;
|
|
1623
|
+
Promise.resolve().then(function () { return transport.listAsks(); }).then(function (asks) {
|
|
1624
|
+
/* 期间换了会话:这一拍作废(新会话有自己的加载与轮询)。 */
|
|
1625
|
+
if (sid !== state.session || state.busy) return;
|
|
1626
|
+
var run = runningAskOf(asks, sid);
|
|
1627
|
+
/* 同一条 run 只刷一次:接不上(旧网关缺 askEvents)时别每 5s 重画一遍。 */
|
|
1628
|
+
if (!run || watchSeen === run.id) return;
|
|
1629
|
+
watchSeen = run.id;
|
|
1630
|
+
clearThread();
|
|
1631
|
+
threadEl.appendChild(empty);
|
|
1632
|
+
state.askId = null;
|
|
1633
|
+
state.renderedSeq = {};
|
|
1634
|
+
return loadHistory(sid).catch(function () { /* 读不到就等下一拍 */ });
|
|
1635
|
+
}).catch(function () { /* 网络抖动 / 旧网关:静默 */ })
|
|
1636
|
+
.then(function () { if (sid === state.session) watchInflight = false; });
|
|
1637
|
+
}
|
|
1638
|
+
function startWatch() {
|
|
1639
|
+
stopWatch();
|
|
1640
|
+
if (!state.session) return;
|
|
1641
|
+
watchTimer = setInterval(watchTick, WATCH_MS);
|
|
1073
1642
|
}
|
|
1074
1643
|
|
|
1075
1644
|
function syncPickers() {
|
|
@@ -1087,6 +1656,9 @@
|
|
|
1087
1656
|
chip.title = engineSupportsPermission(state.engine) ? t.hint
|
|
1088
1657
|
: (state.engine || '当前引擎') + ' 不支持显式选授权档位(仅 claude / codebuddy / codebuddy-ai),会沿用 core 默认';
|
|
1089
1658
|
}
|
|
1659
|
+
/* 选择器是状态变化最集中的出口(换引擎 / 换模型 / 拉到清单 / 切入历史会话都经过它)——
|
|
1660
|
+
顺手把内核要读的那几个值同步到 window(见下面的 syncHostState)。 */
|
|
1661
|
+
syncHostState();
|
|
1090
1662
|
}
|
|
1091
1663
|
|
|
1092
1664
|
/* ══════════ 搭 DOM ══════════
|
|
@@ -1094,17 +1666,41 @@
|
|
|
1094
1666
|
都指向它)—— 所以**不再套内层 wrapper**,宽屏居中靠 CSS 的对称 padding。 */
|
|
1095
1667
|
var wrap = el('div', 'conv');
|
|
1096
1668
|
var threadEl = el('div', 'conv__thread');
|
|
1669
|
+
/* ⚠️ 这块容器就是**内核的画布**:内核那批函数按 `#qa-stream` 找地方落笔
|
|
1670
|
+
(qaRenderMine / qaScrollIntoView / 收尾行),所以 id 必须叫这个,
|
|
1671
|
+
而且**一页只能有一个** —— 宿主页里那份独立的 #qa-stream 已随之撤掉(块②后半)。 */
|
|
1672
|
+
threadEl.id = 'qa-stream';
|
|
1097
1673
|
ctx.thread = threadEl;
|
|
1674
|
+
/* 「加载更早」入口:画布的**常驻第一个孩子**(内核只往尾部追加,不会碰它)。
|
|
1675
|
+
默认藏起来,首屏分页拿到 `more` 后由 syncMore 亮出来;clearThread 清空画布后补回。 */
|
|
1676
|
+
var moreBtn = el('button', 'conv__more');
|
|
1677
|
+
moreBtn.type = 'button';
|
|
1678
|
+
moreBtn.hidden = true;
|
|
1679
|
+
moreBtn.textContent = '向上滚动加载更早的消息';
|
|
1680
|
+
moreBtn.addEventListener('click', function () { loadMore(); });
|
|
1681
|
+
threadEl.appendChild(moreBtn);
|
|
1098
1682
|
wrap.appendChild(threadEl);
|
|
1099
1683
|
/* 组件内不需要「回到底部」按钮,留个游离节点承接保留段落里的赋值,不让它报错 */
|
|
1100
1684
|
ctx.bottomBtn = el('span', 'tobottom');
|
|
1101
1685
|
|
|
1102
1686
|
var form = el('form', 'composer');
|
|
1103
1687
|
var cbox = el('div', 'composer__box');
|
|
1688
|
+
/* 提问卡的**停靠位**:内核按固定 id 找它(`home-askdock`,见 shared/conv/06-ask-answer.js),
|
|
1689
|
+
引擎提问 / 待授权时卡钉在输入框上方。显隐与进出全由内核管,这里只给一块地。 */
|
|
1690
|
+
var askDock = el('div', 'qa-askdock'); askDock.id = 'home-askdock'; askDock.hidden = true;
|
|
1691
|
+
cbox.appendChild(askDock);
|
|
1692
|
+
var attachBar = el('div', 'attachbar'); attachBar.hidden = true;
|
|
1693
|
+
ctx.attachBar = attachBar;
|
|
1694
|
+
cbox.appendChild(attachBar);
|
|
1104
1695
|
ctx.input = el('textarea'); ctx.input.rows = 1; ctx.input.placeholder = '回复 Agent';
|
|
1105
1696
|
cbox.appendChild(ctx.input);
|
|
1106
1697
|
var crow = el('div', 'composer__row');
|
|
1107
1698
|
var cleft = el('div', 'composer__left');
|
|
1699
|
+
if (opts.showAttach) {
|
|
1700
|
+
ctx.attachBtn = el('button', 'iconbtn'); ctx.attachBtn.type = 'button'; ctx.attachBtn.setAttribute('aria-label', '添加');
|
|
1701
|
+
ctx.attachBtn.appendChild(svgIcon('i-plus', 18));
|
|
1702
|
+
cleft.appendChild(ctx.attachBtn);
|
|
1703
|
+
}
|
|
1108
1704
|
var casc = el('button', 'casc');
|
|
1109
1705
|
casc.type = 'button';
|
|
1110
1706
|
casc.appendChild(svgIcon('i-cpu', 14));
|
|
@@ -1117,11 +1713,30 @@
|
|
|
1117
1713
|
ctx.permBtn.type = 'button';
|
|
1118
1714
|
ctx.permBtn.appendChild(svgIcon('i-set', 14));
|
|
1119
1715
|
ctx.permBtn.appendChild(el('span', null, '授权 · 沿用默认'));
|
|
1120
|
-
|
|
1716
|
+
/* 参数行(引擎 · 模型 + 授权档位)**浮在输入框整个上方**,不属于那个圆角框
|
|
1717
|
+
(2026-10-20 用户定稿 B 方案)。原来它们与 + / 发送挤在同一行,`.casc` 只能分到
|
|
1718
|
+
280px 里的一半,引擎名和模型名各被截一半。现在整行宽度都是它们的,且框内只剩
|
|
1719
|
+
输入与动作。 */
|
|
1720
|
+
var trow = el('div', 'composer__tools');
|
|
1721
|
+
trow.appendChild(casc);
|
|
1722
|
+
if (opts.showPermission !== false) trow.appendChild(ctx.permBtn);
|
|
1723
|
+
/* 「生成中」提示行(2026-10-20 用户:「两个胶囊合并成1个 在下面显示生成中效果即可」):
|
|
1724
|
+
画布底部内核那枚「引擎 · 模型 · 生成中」胶囊,与本行这枚「引擎 · 模型」胶囊
|
|
1725
|
+
显示的是同一串字、上下挨着,像同一件事说了两遍 → 画布那枚在本壳**不画**
|
|
1726
|
+
(皮肤见 conversation.css 的 .conv__thread .qa-run);「这一轮在跑」改由
|
|
1727
|
+
这枚胶囊**下面**这一行小字表达(转圈 + 「生成中」)。
|
|
1728
|
+
⚠️ 引擎·模型的真值仍在 .casc 上:降级换模型时 'start' 事件会改写它
|
|
1729
|
+
(见 kernelApply 的 'start' 分支 → syncPickers),所以不丢信息。 */
|
|
1730
|
+
var genRow = el('div', 'composer__gen');
|
|
1731
|
+
genRow.appendChild(el('span', 'composer__gen__sp'));
|
|
1732
|
+
genRow.appendChild(el('span', 'composer__gen__t', '生成中'));
|
|
1733
|
+
ctx.genRow = genRow;
|
|
1121
1734
|
ctx.sendBtn = el('button', 'sendbtn');
|
|
1122
1735
|
ctx.sendBtn.type = 'submit';
|
|
1123
1736
|
crow.appendChild(cleft); crow.appendChild(ctx.sendBtn);
|
|
1124
1737
|
cbox.appendChild(crow);
|
|
1738
|
+
form.appendChild(trow);
|
|
1739
|
+
form.appendChild(genRow);
|
|
1125
1740
|
form.appendChild(cbox);
|
|
1126
1741
|
wrap.appendChild(form);
|
|
1127
1742
|
root.appendChild(wrap);
|
|
@@ -1131,17 +1746,26 @@
|
|
|
1131
1746
|
threadEl.appendChild(empty);
|
|
1132
1747
|
|
|
1133
1748
|
/* ══════════ 接线 ══════════ */
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1749
|
+
/* 代码块与工具明细的复制按钮、提问卡选项、思考块折叠……这些点击**全部**走内核那份委托
|
|
1750
|
+
(`shared/conv/07-stream-delegation.js` 的 bindQaStreamDelegation,在下面挂桥那段绑一次)。
|
|
1751
|
+
本文件不再挂自己的画布委托 —— 节点随快照重建也不会失效。 */
|
|
1752
|
+
threadEl.addEventListener('scroll', function () {
|
|
1753
|
+
state.autoFollow = nearBottom();
|
|
1754
|
+
syncBottomBtn();
|
|
1755
|
+
/* 滚到顶再往上 = 翻页(页首对齐到 user,见 loadMore)。 */
|
|
1756
|
+
if (threadEl.scrollTop <= 8) loadMore();
|
|
1137
1757
|
});
|
|
1138
|
-
|
|
1758
|
+
/* 内容不足一屏时没有 scroll 事件,只能靠滚轮方向兜住「想往上翻」这个动作。 */
|
|
1759
|
+
threadEl.addEventListener('wheel', function (e) {
|
|
1760
|
+
if (e.deltaY < 0 && threadEl.scrollHeight <= threadEl.clientHeight + 8) loadMore();
|
|
1761
|
+
}, { passive: true });
|
|
1139
1762
|
ctx.input.addEventListener('input', autoGrow);
|
|
1140
1763
|
form.addEventListener('submit', function (e) { e.preventDefault(); send(); });
|
|
1141
1764
|
ctx.input.addEventListener('keydown', function (e) {
|
|
1142
1765
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); }
|
|
1143
1766
|
});
|
|
1144
1767
|
casc.addEventListener('click', function (e) { e.stopPropagation(); openEnginePicker(casc); });
|
|
1768
|
+
if (ctx.attachBtn) ctx.attachBtn.addEventListener('click', function (e) { e.stopPropagation(); openAttachSheet(ctx.attachBtn); });
|
|
1145
1769
|
ctx.permBtn.addEventListener('click', function (e) { e.stopPropagation(); openPermissionPicker(ctx.permBtn); });
|
|
1146
1770
|
ctx.sendBtn.addEventListener('click', function (e) {
|
|
1147
1771
|
if (!state.busy) return; // 空闲时照常 submit
|
|
@@ -1149,47 +1773,347 @@
|
|
|
1149
1773
|
control('interrupt');
|
|
1150
1774
|
});
|
|
1151
1775
|
|
|
1152
|
-
/*
|
|
1776
|
+
/* ── 宿主状态回写(内核把 `qaEngine` / `qaModel` / … 当**变量**读)──
|
|
1777
|
+
内核渲染角色行、判断模型降级、拼产物地址都要这几个值;它们在组件闭包里,
|
|
1778
|
+
所以每次状态变化(选引擎 / 选模型 / 认到会话 id / 拉到引擎清单)都同步一份到 window。
|
|
1779
|
+
(内核侧那批符号是值不是函数,见 shared/host-glue.js 第三节的说明。) */
|
|
1780
|
+
function syncHostState() {
|
|
1781
|
+
try {
|
|
1782
|
+
window.qaEngine = state.engine || '';
|
|
1783
|
+
window.qaModel = state.model || '';
|
|
1784
|
+
window.qaModelPick = state.model || '';
|
|
1785
|
+
window.qaEngines = Array.isArray(state.engines) ? state.engines : [];
|
|
1786
|
+
window.qaActiveId = state.session || '';
|
|
1787
|
+
window.qaWorkspace = state.workspace || '';
|
|
1788
|
+
} catch (e) { /* 非浏览器环境(测试桩)忽略 */ }
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/* ── 宿主能力桥(`window.MagicHost`):内核里「只能由宿主做」的三件事转回本组件 ──
|
|
1792
|
+
① 重试 / 追问 / 提问卡作答 → 重新发一条(本壳的 send / control);
|
|
1793
|
+
② 模型显示名(内核的「引擎 · 模型」小标要用它);
|
|
1794
|
+
③ 轻提示(本壳没有 toast 位,如实进 console,不假装弹过)。 */
|
|
1795
|
+
window.MagicHost = window.MagicHost || {};
|
|
1796
|
+
window.MagicHost.send = function (text, o) {
|
|
1797
|
+
var t = String(text == null ? '' : text).trim();
|
|
1798
|
+
if (!t) return;
|
|
1799
|
+
/* 引擎正等这一问的答案 → 走控制通道(与「作答」原语义一致);当成新的一轮发出去的话,
|
|
1800
|
+
引擎那边还在等,这一问就断了。 */
|
|
1801
|
+
if (state.awaitingAnswer) { control('answer', t); return; }
|
|
1802
|
+
state.noEcho = !!(o && o.echo === false); // 内核的重试路径:那条气泡已经在画布上了
|
|
1803
|
+
ctx.input.value = t;
|
|
1804
|
+
send();
|
|
1805
|
+
};
|
|
1806
|
+
window.MagicHost.model = {
|
|
1807
|
+
cur: function () { return state.model || ''; },
|
|
1808
|
+
name: function (id) { return id ? String(id) : (state.model || ''); },
|
|
1809
|
+
set: function (id) { state.model = id ? String(id) : ''; syncPickers(); return true; },
|
|
1810
|
+
};
|
|
1811
|
+
window.MagicHost.toast = function (msg) { console.info('[conv] ' + String(msg == null ? '' : msg)); };
|
|
1812
|
+
/* 内核自带的点击委托(复制 / 重试 / 提问卡选项 / 思考块与待办折叠)——
|
|
1813
|
+
绑一次就够(内部有幂等标记);它做的是**事件委托**,节点随快照重建也不怕。 */
|
|
1814
|
+
if (typeof bindQaStreamDelegation === 'function') bindQaStreamDelegation();
|
|
1815
|
+
|
|
1816
|
+
/* ── 附件(+):拍照 / 照片·视频 / 添加文件 + 专家 / 技能 / 连接器 / 定时任务 ──
|
|
1817
|
+
文件先 POST 给宿主(transport.upload)拿到 core 所在机器的**绝对路径**,
|
|
1818
|
+
再由 ask 的 payload.attachments 透传给 -a/--attach —— 附件是本地路径,跨机无意义。 */
|
|
1819
|
+
/* 附件视觉:图片出缩略图,其余退回文件名 chip。onRemove 为空 = 只读(用户气泡 / 历史回放)。
|
|
1820
|
+
历史附件没有本地 objectURL,靠回读地址(transport.mediaUrl);回读失败就地退回 chip,不留破图。 */
|
|
1821
|
+
function thumbNode(a, onRemove, removeArg) {
|
|
1822
|
+
var node = el('span', 'attachthumb');
|
|
1823
|
+
var img = el('img', 'attachthumb__img');
|
|
1824
|
+
img.src = a.url || a.remoteUrl || '';
|
|
1825
|
+
img.alt = a.name || '';
|
|
1826
|
+
img.onerror = function () {
|
|
1827
|
+
var chip = el('span', 'attachchip');
|
|
1828
|
+
chip.appendChild(el('span', 'attachchip__n', a.name || '附件'));
|
|
1829
|
+
if (node.parentNode) node.parentNode.replaceChild(chip, node);
|
|
1830
|
+
};
|
|
1831
|
+
node.appendChild(img);
|
|
1832
|
+
if (a.uploading) node.appendChild(el('span', 'attachthumb__mask', '上传中…'));
|
|
1833
|
+
else if (a.error) node.appendChild(el('span', 'attachthumb__mask attachthumb__mask--err', '失败'));
|
|
1834
|
+
if (onRemove) {
|
|
1835
|
+
var x = el('button', 'attachthumb__x', '✕');
|
|
1836
|
+
x.type = 'button'; x.setAttribute('aria-label', '移除');
|
|
1837
|
+
// removeArg 是列表里的**原对象**:渲染用的是包装对象,移除必须按原对象身份查(否则 indexOf 恒为 -1)。
|
|
1838
|
+
x.onclick = function () { onRemove(removeArg || a); };
|
|
1839
|
+
node.appendChild(x);
|
|
1840
|
+
}
|
|
1841
|
+
node.title = a.name || '';
|
|
1842
|
+
return node;
|
|
1843
|
+
}
|
|
1844
|
+
function chipNode(a, onRemove, removeArg) {
|
|
1845
|
+
var node = el('span', 'attachchip');
|
|
1846
|
+
node.appendChild(el('span', 'attachchip__n', a.name || '附件'));
|
|
1847
|
+
if (onRemove) {
|
|
1848
|
+
var x = el('button', 'attachchip__x', '✕');
|
|
1849
|
+
x.type = 'button'; x.setAttribute('aria-label', '移除');
|
|
1850
|
+
x.onclick = function () { onRemove(removeArg || a); };
|
|
1851
|
+
node.appendChild(x);
|
|
1852
|
+
}
|
|
1853
|
+
return node;
|
|
1854
|
+
}
|
|
1855
|
+
/* 统一两种形态:发送中的附件(name/url/remoteUrl)与历史附件(path/mime)。 */
|
|
1856
|
+
function attNode(a, onRemove) {
|
|
1857
|
+
// 历史附件只有绝对路径;文件名去掉上传时加的 <UnixNano>- 前缀,还原成用户看到的名字。
|
|
1858
|
+
var name = a && a.name ? String(a.name)
|
|
1859
|
+
: (a && a.path ? String(a.path).split(/[\\/]/).pop().replace(/^\d{13,}-/, '') : '附件');
|
|
1860
|
+
var url = a && a.url ? a.url : '';
|
|
1861
|
+
var remote = a && a.remoteUrl ? a.remoteUrl : '';
|
|
1862
|
+
if (!url && !remote && a && a.path && transport.mediaUrl) remote = transport.mediaUrl(a.path);
|
|
1863
|
+
var mime = a && a.mime ? String(a.mime) : '';
|
|
1864
|
+
var isImg = !!url || !!remote || /^image\//i.test(mime) || /\.(png|jpe?g|gif|webp|bmp)$/i.test(name);
|
|
1865
|
+
if (isImg && (url || remote)) {
|
|
1866
|
+
return thumbNode({ name: name, url: url, remoteUrl: remote, uploading: a.uploading, error: a.error }, onRemove, a);
|
|
1867
|
+
}
|
|
1868
|
+
return chipNode({ name: name }, onRemove, a);
|
|
1869
|
+
}
|
|
1870
|
+
/* 附件条:图片出缩略图(objectURL 立刻可见),其余类型退回文件名 chip。 */
|
|
1871
|
+
function renderAttach() {
|
|
1872
|
+
var bar = ctx.attachBar;
|
|
1873
|
+
if (!bar) return;
|
|
1874
|
+
bar.innerHTML = '';
|
|
1875
|
+
var list = state.attachments || [];
|
|
1876
|
+
bar.hidden = !list.length;
|
|
1877
|
+
list.forEach(function (a) { bar.appendChild(attNode(a, removeAttachment)); });
|
|
1878
|
+
}
|
|
1879
|
+
/* 按对象身份移除(不要用下标:上传是异步的,多次渲染后下标会漂)。 */
|
|
1880
|
+
function removeAttachment(a) {
|
|
1881
|
+
var i = (state.attachments || []).indexOf(a);
|
|
1882
|
+
if (i < 0) return;
|
|
1883
|
+
if (a.url && typeof URL !== 'undefined' && URL.revokeObjectURL) {
|
|
1884
|
+
try { URL.revokeObjectURL(a.url); } catch (e) {}
|
|
1885
|
+
}
|
|
1886
|
+
state.attachments.splice(i, 1);
|
|
1887
|
+
renderAttach();
|
|
1888
|
+
}
|
|
1889
|
+
/* 发送后清空:连同仍在持有(未发走)的 objectURL 一起释放。 */
|
|
1890
|
+
function clearAttachments() {
|
|
1891
|
+
(state.attachments || []).forEach(function (a) {
|
|
1892
|
+
if (a.url && typeof URL !== 'undefined' && URL.revokeObjectURL) {
|
|
1893
|
+
try { URL.revokeObjectURL(a.url); } catch (e) {}
|
|
1894
|
+
}
|
|
1895
|
+
});
|
|
1896
|
+
state.attachments = [];
|
|
1897
|
+
renderAttach();
|
|
1898
|
+
}
|
|
1899
|
+
function pickFiles(accept, capture, multiple, onPick) {
|
|
1900
|
+
var inp = document.createElement('input');
|
|
1901
|
+
inp.type = 'file';
|
|
1902
|
+
if (accept) inp.accept = accept;
|
|
1903
|
+
if (capture) inp.setAttribute('capture', capture);
|
|
1904
|
+
if (multiple) inp.multiple = true;
|
|
1905
|
+
inp.style.display = 'none';
|
|
1906
|
+
document.body.appendChild(inp);
|
|
1907
|
+
inp.addEventListener('change', function () {
|
|
1908
|
+
var files = Array.prototype.slice.call(inp.files || []);
|
|
1909
|
+
inp.remove();
|
|
1910
|
+
if (files.length) onPick(files);
|
|
1911
|
+
});
|
|
1912
|
+
inp.click();
|
|
1913
|
+
}
|
|
1914
|
+
/* 转码参数:转码后最长边 / 「已是引擎认识类型」的体积上限(超过就压一压)。
|
|
1915
|
+
1920 与 PC 端的 ATT_EDGE 同口径(界面文字还看得清,又不至于拖慢每轮对话)。 */
|
|
1916
|
+
var IMG_EDGE = 1920;
|
|
1917
|
+
var IMG_EDGE_BYTES = 2 * 1024 * 1024;
|
|
1918
|
+
/* 相册原图可能是 HEIC/HEIF/AVIF 或超大图 —— 引擎的图片块只吃 png/jpeg/gif/webp,
|
|
1919
|
+
识别不了就会被降级成「把路径写进提示词」,模型只能 OCR(见 PC 端 07-chat-stream-render 同款注释)。
|
|
1920
|
+
策略:类型已是引擎认识的且不大 → 原样传;否则 canvas 解码后统一转 JPEG(顺带缩到长边 ≤ IMG_EDGE)。 */
|
|
1921
|
+
function maybeNormalize(f) {
|
|
1922
|
+
var type = f.type || '';
|
|
1923
|
+
var looksImg = /^image\//i.test(type) || /\.(png|jpe?g|gif|webp|bmp|heic|heif|avif)$/i.test(f.name || '');
|
|
1924
|
+
var apiType = /^image\/(png|jpeg|jpg|gif|webp|bmp)$/i.test(type);
|
|
1925
|
+
if (!looksImg) return Promise.resolve(f);
|
|
1926
|
+
if (apiType && f.size <= IMG_EDGE_BYTES) return Promise.resolve(f);
|
|
1927
|
+
return normalizeImage(f).then(function (out) { return out || f; });
|
|
1928
|
+
}
|
|
1929
|
+
function normalizeImage(file) {
|
|
1930
|
+
return new Promise(function (resolve) {
|
|
1931
|
+
var objUrl = '';
|
|
1932
|
+
var finish = function (out) {
|
|
1933
|
+
if (objUrl && typeof URL !== 'undefined' && URL.revokeObjectURL) { try { URL.revokeObjectURL(objUrl); } catch (e) {} }
|
|
1934
|
+
resolve(out);
|
|
1935
|
+
};
|
|
1936
|
+
function decodeViaImg(ff) {
|
|
1937
|
+
return new Promise(function (res, rej) {
|
|
1938
|
+
objUrl = URL.createObjectURL(ff);
|
|
1939
|
+
var im = new Image();
|
|
1940
|
+
im.onload = function () { res(im); };
|
|
1941
|
+
im.onerror = function () { rej(new Error('decode failed')); };
|
|
1942
|
+
im.src = objUrl;
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
var decode = (typeof createImageBitmap === 'function')
|
|
1946
|
+
? createImageBitmap(file).catch(function () { return decodeViaImg(file); })
|
|
1947
|
+
: decodeViaImg(file);
|
|
1948
|
+
decode.then(function (img) {
|
|
1949
|
+
var w = img.width || img.naturalWidth || 0, h = img.height || img.naturalHeight || 0;
|
|
1950
|
+
if (!w || !h) return finish(null);
|
|
1951
|
+
var scale = Math.min(1, IMG_EDGE / Math.max(w, h));
|
|
1952
|
+
var tw = Math.max(1, Math.round(w * scale)), th = Math.max(1, Math.round(h * scale));
|
|
1953
|
+
var cv = document.createElement('canvas'); cv.width = tw; cv.height = th;
|
|
1954
|
+
var cx = cv.getContext('2d');
|
|
1955
|
+
if (!cx) return finish(null);
|
|
1956
|
+
try { cx.drawImage(img, 0, 0, tw, th); } catch (e) { return finish(null); }
|
|
1957
|
+
if (img.close) { try { img.close(); } catch (e) {} }
|
|
1958
|
+
var base = String(file.name || 'image').replace(/\.[^.]+$/, '') || 'image';
|
|
1959
|
+
cv.toBlob(function (blob) {
|
|
1960
|
+
if (!blob) return finish(null);
|
|
1961
|
+
var out;
|
|
1962
|
+
try { out = new File([blob], base + '.jpg', { type: 'image/jpeg' }); }
|
|
1963
|
+
catch (e) { out = blob; } // 老内核没有 File 构造器:退回 Blob(后端按魔数嗅探,不受影响)
|
|
1964
|
+
finish(out);
|
|
1965
|
+
}, 'image/jpeg', 0.85);
|
|
1966
|
+
}).catch(function () { finish(null); });
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
function uploadFiles(files) {
|
|
1970
|
+
var chain = Promise.resolve();
|
|
1971
|
+
files.forEach(function (f) {
|
|
1972
|
+
chain = chain.then(function () {
|
|
1973
|
+
if (!transport.upload) { noticeItem('当前传输层不支持上传附件'); return; }
|
|
1974
|
+
/* 先转码再传:保证引擎收到的是它认识的图片格式。 */
|
|
1975
|
+
return maybeNormalize(f).then(function (up) {
|
|
1976
|
+
var isImg = /^image\//i.test(up.type || '');
|
|
1977
|
+
var nm = up.name || f.name;
|
|
1978
|
+
var item = { path: '', name: nm, size: up.size, url: '', remoteUrl: '', uploading: true, error: '' };
|
|
1979
|
+
if (isImg && typeof URL !== 'undefined' && URL.createObjectURL) {
|
|
1980
|
+
try { item.url = URL.createObjectURL(up); } catch (e) {}
|
|
1981
|
+
}
|
|
1982
|
+
state.attachments.push(item);
|
|
1983
|
+
renderAttach();
|
|
1984
|
+
return transport.upload(up).then(function (r) {
|
|
1985
|
+
item.path = r.path;
|
|
1986
|
+
item.name = r.name || nm;
|
|
1987
|
+
item.size = r.size;
|
|
1988
|
+
if (isImg) item.remoteUrl = r.url || '';
|
|
1989
|
+
item.uploading = false;
|
|
1990
|
+
renderAttach();
|
|
1991
|
+
}).catch(function (e) {
|
|
1992
|
+
item.uploading = false;
|
|
1993
|
+
item.error = (e && e.message ? e.message : String(e));
|
|
1994
|
+
renderAttach();
|
|
1995
|
+
noticeItem('上传失败:' + item.error);
|
|
1996
|
+
});
|
|
1997
|
+
});
|
|
1998
|
+
});
|
|
1999
|
+
});
|
|
2000
|
+
return chain;
|
|
2001
|
+
}
|
|
2002
|
+
function openAttachSheet(anchor) {
|
|
2003
|
+
Sheet.open(anchor, function (M) {
|
|
2004
|
+
M.title('添加');
|
|
2005
|
+
var grid = el('div', 'attachgrid');
|
|
2006
|
+
function tile(icon, label, onTap) {
|
|
2007
|
+
var b = el('button', 'tile'); b.type = 'button';
|
|
2008
|
+
b.appendChild(svgIcon(icon, 22));
|
|
2009
|
+
b.appendChild(el('span', 'tile__l', label));
|
|
2010
|
+
b.onclick = onTap; grid.appendChild(b);
|
|
2011
|
+
}
|
|
2012
|
+
tile('i-cam', '拍照', function () { Sheet.close(); pickFiles('image/*', 'environment', false, uploadFiles); });
|
|
2013
|
+
tile('i-img', '照片 / 视频', function () { Sheet.close(); pickFiles('image/*,video/*', null, true, uploadFiles); });
|
|
2014
|
+
tile('i-doc', '添加文件', function () { Sheet.close(); pickFiles(null, null, true, uploadFiles); });
|
|
2015
|
+
M.node(grid);
|
|
2016
|
+
var rows = [['i-user', '专家', 'specialist'], ['i-spark', '技能', 'skills'], ['i-link', '连接器', 'connectors'], ['i-clock', '定时任务', 'scheduled']];
|
|
2017
|
+
rows.forEach(function (r) {
|
|
2018
|
+
var b = el('button', 'sheetrow'); b.type = 'button';
|
|
2019
|
+
b.appendChild(svgIcon(r[0], 18));
|
|
2020
|
+
b.appendChild(el('span', 'sheetrow__l', r[1]));
|
|
2021
|
+
b.appendChild(svgIcon('i-chev', 16));
|
|
2022
|
+
b.onclick = function () {
|
|
2023
|
+
Sheet.close();
|
|
2024
|
+
if (r[2] === 'scheduled' && opts.onMenu) { try { opts.onMenu('scheduled'); } catch (e) {} return; }
|
|
2025
|
+
noticeItem(r[1] + ':暂未接入(需要后端提供清单接口)');
|
|
2026
|
+
};
|
|
2027
|
+
M.node(b);
|
|
2028
|
+
});
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/* 首个事件进来就把空态拿走(骨架屏同理:真内容一到,占位就该让位) */
|
|
1153
2033
|
var _render = render;
|
|
1154
2034
|
render = function (ev) {
|
|
1155
2035
|
if (empty.parentNode) empty.remove();
|
|
2036
|
+
if (histLoadingWant) endHistLoading(false);
|
|
1156
2037
|
if (opts.onEvent) { try { opts.onEvent(ev); } catch (e) {} }
|
|
1157
2038
|
return _render(ev);
|
|
1158
2039
|
};
|
|
1159
2040
|
|
|
2041
|
+
/* 新会话的标题文案:「打开会话」那一支是 setSession,这两支互斥。
|
|
2042
|
+
⚠️ 必须在**挂载时**也写一次,不能只在 newSession() 里写:移动端「新建任务」不是调
|
|
2043
|
+
newSession(),而是销毁组件后重新 create、复用同一个 #title(client-ui-mobile/index.html
|
|
2044
|
+
的 mountConversation)—— 新实例不写,标题就留着上一个会话的(2026-10-20 用户:
|
|
2045
|
+
「新会话不应该显示别的会话的标题」)。 */
|
|
2046
|
+
function newTitleText() { return state.engine ? ('新会话 · ' + state.engine) : '新会话'; }
|
|
2047
|
+
function syncNewTitle() { if (ctx.titleEl && !state.session) ctx.titleEl.textContent = newTitleText(); }
|
|
1160
2048
|
function newSession() {
|
|
2049
|
+
stopWatch();
|
|
1161
2050
|
state.session = null; state.askId = null;
|
|
1162
2051
|
clearThread();
|
|
2052
|
+
clearAttachments(); // 新会话不继承上一段会话待发的附件
|
|
2053
|
+
ctx.input.value = ''; autoGrow();
|
|
1163
2054
|
threadEl.appendChild(empty);
|
|
1164
|
-
|
|
2055
|
+
syncNewTitle();
|
|
1165
2056
|
setTimeout(function () { ctx.input.focus(); }, 0);
|
|
1166
2057
|
}
|
|
1167
2058
|
function setSession(sid, s) {
|
|
2059
|
+
stopWatch(); // 上一段会话的「新轮次发现」不许落进这一段
|
|
1168
2060
|
state.session = sid || null;
|
|
1169
2061
|
if (s && s.engine) { state.engine = s.engine; }
|
|
2062
|
+
/* 模型跟引擎一起继承(2026-10-20 用户:「没有显示历史会话对应的模型 引擎显示了」)——
|
|
2063
|
+
清单行里带着这条会话真正用的 `model`(core 的 --sessions,见 session.Record),
|
|
2064
|
+
不接过来就会一直显示宿主的全局偏好。与 PC 工作台同一口径(93-rail.js 的
|
|
2065
|
+
`t.model = s.model || ""`):空值 = 让 core 用默认档,不替用户预设。 */
|
|
2066
|
+
state.model = (s && s.model) || '';
|
|
1170
2067
|
clearThread();
|
|
1171
2068
|
threadEl.appendChild(empty);
|
|
2069
|
+
/* ⚠️ 会话标题**以 core 的 `title` 为准**(2026-10-18 用户:「同一个会话 pc 端还是跟移动端
|
|
2070
|
+
标题不一样 需一致」)。core 的 `--sessions` 早已把「标题表的归纳结果 → prompt_head」
|
|
2071
|
+
合并成一个 `title` 字段,列表页(移动端 titleOf / PC 掌天瓶的 cliRows)读的都是它;
|
|
2072
|
+
只有**这个详情页顶栏**此前优先 `prompt_head` —— 于是同一条会话:PC 显示归纳要点
|
|
2073
|
+
(如「完整复刻水世界场景」),移动端详情页显示首问原文(如「现在水世界跟参考图还不一样…」)。
|
|
2074
|
+
老 core 没有 `title` 字段 → 回落 `prompt_head`;再没有就用会话 id 前 8 位。 */
|
|
1172
2075
|
if (ctx.titleEl) ctx.titleEl.textContent =
|
|
1173
|
-
(s && (s.
|
|
2076
|
+
(s && (s.title || s.prompt_head)) || (sid ? String(sid).slice(0, 8) : '会话');
|
|
1174
2077
|
syncPickers();
|
|
1175
2078
|
if (sid) {
|
|
1176
|
-
|
|
2079
|
+
startWatch();
|
|
2080
|
+
/* watchInflight:这次首载期间别让 watcher 再插一次 loadHistory(大日志可能超过 5s)。 */
|
|
2081
|
+
watchInflight = true;
|
|
2082
|
+
loadHistory(sid)
|
|
2083
|
+
.catch(function (e) { noticeItem('历史加载失败:' + (e.message || e)); })
|
|
2084
|
+
.then(function () { if (sid === state.session) watchInflight = false; });
|
|
1177
2085
|
}
|
|
1178
|
-
|
|
2086
|
+
/* 打开一条已有的回话(看历史 / 接着聊)时**不**自动把焦点塞进输入框:
|
|
2087
|
+
移动端上这一下会直接顶起软键盘,刚回放出来的历史被键盘挡掉大半。
|
|
2088
|
+
要打字时用户自己点输入框即可;newSession(用户主动「新建」)仍保留聚焦。 */
|
|
1179
2089
|
}
|
|
1180
2090
|
function refreshEngines() {
|
|
1181
2091
|
return Promise.resolve(transport.listEngines()).then(function (list) {
|
|
1182
2092
|
state.engines = Array.isArray(list) ? list : [];
|
|
2093
|
+
/* 记住的引擎已不在本机(卸载 / 改名 / 清单变了)→ 回首个可用。
|
|
2094
|
+
不做这步的话,带着一个跑不起来的引擎发任务,报错指向 core 而不是
|
|
2095
|
+
「这个引擎没了」;宿主记的默认值也会一直卡在死引擎上。
|
|
2096
|
+
(清单为空 = 探测失败,保持原引擎不动,别把用户的默认值清掉。) */
|
|
2097
|
+
var okRows = state.engines.filter(function (x) { return x.ok; });
|
|
1183
2098
|
if (!state.engine) {
|
|
1184
|
-
var
|
|
1185
|
-
if (
|
|
2099
|
+
var firstOk = okRows[0];
|
|
2100
|
+
if (firstOk) state.engine = firstOk.engine; // 「自动」档:静默取首个可用,不回写宿主
|
|
2101
|
+
} else if (!okRows.some(function (x) { return x.engine === state.engine; })) {
|
|
2102
|
+
var fallback = okRows[0];
|
|
2103
|
+
if (fallback) {
|
|
2104
|
+
state.engine = fallback.engine;
|
|
2105
|
+
state.model = '';
|
|
2106
|
+
notifyPick(); // 宿主把默认值同步改成实际会用的引擎
|
|
2107
|
+
}
|
|
1186
2108
|
}
|
|
1187
2109
|
syncPickers();
|
|
2110
|
+
syncNewTitle(); // 引擎清单是异步到的:新会话标题里那枚引擎名跟着落定
|
|
1188
2111
|
return state.engines;
|
|
1189
2112
|
});
|
|
1190
2113
|
}
|
|
1191
2114
|
function destroy() {
|
|
1192
|
-
|
|
2115
|
+
stopWatch();
|
|
2116
|
+
Sheet.close();
|
|
1193
2117
|
if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
|
|
1194
2118
|
}
|
|
1195
2119
|
|
|
@@ -1198,6 +2122,7 @@
|
|
|
1198
2122
|
setSendMode('idle');
|
|
1199
2123
|
syncPickers();
|
|
1200
2124
|
if (state.session) setSession(state.session);
|
|
2125
|
+
else syncNewTitle(); // 新挂载 = 新会话:标题必须从这里重写,别留着上一实例写的值
|
|
1201
2126
|
|
|
1202
2127
|
return {
|
|
1203
2128
|
version: 1,
|
|
@@ -1210,6 +2135,10 @@
|
|
|
1210
2135
|
setEngine: function (n) { state.engine = n; syncPickers(); },
|
|
1211
2136
|
setModel: function (n) { state.model = n; syncPickers(); },
|
|
1212
2137
|
setPermission: function (v) { state.permission = v; syncPickers(); },
|
|
2138
|
+
setWorkspace: function (v) { state.workspace = v || ''; },
|
|
2139
|
+
permissionLabel: function () { return tierOf(state.permission || '').label; },
|
|
2140
|
+
permissionTier: function () { return state.permission || ''; },
|
|
2141
|
+
openPermissionPicker: openPermissionPicker,
|
|
1213
2142
|
setEngines: function (list) { state.engines = list || []; syncPickers(); },
|
|
1214
2143
|
refreshEngines: refreshEngines,
|
|
1215
2144
|
setSession: setSession,
|
|
@@ -1222,4 +2151,9 @@
|
|
|
1222
2151
|
}
|
|
1223
2152
|
|
|
1224
2153
|
window.MagicConversation = { version: 1, create: create };
|
|
2154
|
+
/* 掌天瓶图形也给宿主用(App 启动闪屏要同一枚瓶,见 client-ui-mobile/index.html 的 .splash):
|
|
2155
|
+
它是**图形**不是对话逻辑,所以放在这个命名空间下而不是 hostSymbols 里 ——
|
|
2156
|
+
宿主拿到的是「一枚造好的 SVG」,不需要知道瓶身怎么画。 */
|
|
2157
|
+
window.MagicConversation.bottle = bottleSVG;
|
|
2158
|
+
window.MagicConversation.BOTTLE_VB = BOTTLE_VB;
|
|
1225
2159
|
})();
|