magic-agent 1.0.49 → 1.0.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CLIENT-CONTRACT.md +514 -7
  2. package/README.md +31 -2
  3. package/client-ui/pc/app.css +3 -41
  4. package/client-ui/pc/js/04-task-list.js +6 -2
  5. package/client-ui/pc/js/90-glue.js +23 -4
  6. package/client-ui/pc/js/92-composer.js +11 -4
  7. package/client-ui/pc/js/93-rail.js +178 -19
  8. package/client-ui/shared/conv/03-chat-message.js +145 -25
  9. package/client-ui/shared/conv/09-stream-apply.js +55 -6
  10. package/client-ui/shared/conv/19-attachments.js +70 -6
  11. package/client-ui/shared/conv/27-conv-ledger.js +18 -0
  12. package/client-ui/shared/conv/29-msg-variants.js +10 -0
  13. package/client-ui/shared/conv/README.md +6 -0
  14. package/client-ui/shared/conv/conv.css +77 -0
  15. package/client-ui/shared/conversation.css +229 -43
  16. package/client-ui/shared/conversation.js +1316 -131
  17. package/client-ui/shared/transport-http.js +164 -12
  18. package/client-ui-mobile/index.html +2443 -178
  19. package/internal/agent/capability.go +16 -2
  20. package/internal/agent/claude.go +3 -2
  21. package/internal/agent/codebuddy.go +7 -1
  22. package/internal/agent/codex_test.go +3 -0
  23. package/internal/agent/custom_models_test.go +5 -0
  24. package/internal/agent/dsh.go +18 -2
  25. package/internal/agent/dsh_test.go +53 -2
  26. package/internal/agent/engine_test.go +34 -19
  27. package/internal/agent/env_test.go +14 -9
  28. package/internal/agent/models_test.go +9 -0
  29. package/internal/agent/runcmd_test.go +24 -3
  30. package/internal/agent/stream.go +33 -0
  31. package/internal/cli/ask.go +94 -4
  32. package/internal/cli/contract_test.go +26 -0
  33. package/internal/cli/events.go +18 -7
  34. package/internal/cli/keepalive_test.go +69 -0
  35. package/internal/cli/requirements.go +119 -0
  36. package/internal/cli/requirements_test.go +36 -0
  37. package/internal/cli/root.go +56 -4
  38. package/internal/cli/session.go +64 -25
  39. package/internal/cli/sessionlog.go +76 -7
  40. package/internal/cli/sessionlog_test.go +58 -0
  41. package/internal/cli/team.go +374 -0
  42. package/internal/cli/team_session.go +156 -0
  43. package/internal/cli/team_test.go +360 -0
  44. package/internal/client/plugins.go +7 -4
  45. package/internal/client/requirements.go +309 -0
  46. package/internal/client/requirements_test.go +239 -0
  47. package/internal/client/service.go +545 -19
  48. package/internal/client/service_test.go +47 -2
  49. package/internal/client/session_events_test.go +320 -0
  50. package/internal/client/team_flags.go +204 -0
  51. package/internal/client/team_flags_test.go +254 -0
  52. package/internal/requirements/bootstrap.go +423 -0
  53. package/internal/requirements/consolidate.go +908 -0
  54. package/internal/requirements/doc.go +473 -0
  55. package/internal/requirements/memory.go +324 -0
  56. package/internal/requirements/requirements.go +169 -0
  57. package/internal/requirements/requirements_test.go +530 -0
  58. package/internal/requirements/store.go +289 -0
  59. package/internal/session/delete.go +8 -4
  60. package/internal/session/session.go +194 -17
  61. package/internal/session/session_append.go +4 -1
  62. package/internal/session/session_append_test.go +19 -0
  63. package/internal/session/session_mark_test.go +42 -3
  64. package/internal/session/session_test.go +43 -0
  65. package/internal/session/sessionlog.go +50 -34
  66. package/internal/session/sessionlog_read_test.go +69 -11
  67. package/internal/session/status.go +205 -0
  68. package/internal/session/status_test.go +144 -0
  69. package/internal/team/adaptive.go +293 -0
  70. package/internal/team/board.go +216 -0
  71. package/internal/team/event.go +28 -0
  72. package/internal/team/execute.go +648 -0
  73. package/internal/team/plan.go +903 -0
  74. package/internal/team/planner.go +294 -0
  75. package/internal/team/reduce.go +192 -0
  76. package/internal/team/role.go +334 -0
  77. package/internal/team/route.go +206 -0
  78. package/internal/team/schedule.go +241 -0
  79. package/internal/team/schedule_test.go +286 -0
  80. package/internal/team/subsession.go +27 -0
  81. package/internal/team/team.go +401 -0
  82. package/internal/team/team_test.go +1997 -0
  83. package/npm/bin/magic-client.js +0 -0
  84. package/npm/dist/darwin-arm64/magic-agent +0 -0
  85. package/npm/dist/darwin-arm64/magic-client +0 -0
  86. package/npm/dist/darwin-x64/magic-agent +0 -0
  87. package/npm/dist/darwin-x64/magic-client +0 -0
  88. package/npm/dist/linux-arm64/magic-agent +0 -0
  89. package/npm/dist/linux-arm64/magic-client +0 -0
  90. package/npm/dist/linux-x64/magic-agent +0 -0
  91. package/npm/dist/linux-x64/magic-client +0 -0
  92. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  93. package/npm/dist/win32-x64/magic-client.exe +0 -0
  94. package/package.json +1 -1
@@ -13,7 +13,10 @@
13
13
  * control({id, op, text?}) -> Promise (op: interrupt | stop | answer)
14
14
  * readSession(id, {after?}) -> Promise<{events, lastSeq?, snapshotRequired?}>
15
15
  * listAsks() -> Promise<[{id, engine, started, session?, run?}]> (可选)
16
- * 打开还在跑的会话时用它续上实时流(见 attachRunningAsk)
16
+ * 网关没有 followSession 时,用它 + askEvents 兜底续流
17
+ * followSession(id, after, h) -> {cancel} (可选,**首选**)
18
+ * 打开还在跑的会话时尾随磁盘日志的增量(SSE);
19
+ * h: {onOpen?, onEvent, onSnapshot?, onEnd?, onError?}
17
20
  * upload(file) -> Promise<{path, name, size, mime, url?}> (可选)
18
21
  * mediaUrl(path) -> string (可选)附件回读地址;有它历史气泡才画得出缩略图
19
22
  * 掌天瓶走 IPC → CLI;别的对接方可能走 HTTP —— 组件对此一无所知。
@@ -30,9 +33,32 @@
30
33
  * showPermission: 可选。false 则不显示输入框下的「授权」选择器(权限仍可用 openPermissionPicker 打开)
31
34
  * onEvent: 可选。每个 core 事件透传一份给宿主(只读)
32
35
  * onBusy: 可选。一轮开始/结束时回调 (busy:boolean)
36
+ * beforeAsk: 可选。**每一轮下发前**的闸门 async (info) => boolean|undefined。
37
+ * info = {isNew, session, workspace, engine, model, permission, prompt,
38
+ * attachments, team, queued(on)}。
39
+ * 返回 false = 宿主判定这一轮不必再发(移动端串行模式里用户取消排队就是这条),
40
+ * 组件会清掉「进行中」态并留一条提示;返回其它任何值(含 undefined)= 照常下发。
41
+ * 宿主在自己的 Promise 里慢慢等(比如排队等前一条跑完)即可,组件不设超时。
42
+ * `queued(true)` = 宿主**确认这一轮要等**,把「排队中」标到气泡上;
43
+ * `queued(false)` 放行时摘掉。组件**不按时间猜**排队态 —— 空闲时的一个请求
44
+ * 往返不该闪一下假的「排队中」(2026-10-22 用户报的就是这个)。
45
+ * onInterrupt: 可选。(prompt) => boolean。用户在这一轮**还没拿到 askId**(还在宿主那边排队)
46
+ * 时点了打断:宿主认领就返回 true(撤掉排队),组件不再发 control。
47
+ * prompt 是刚发出去的那句,宿主据此认出是哪一条在排队。
33
48
  * onPick: 可选。用户在选择器里**落定**引擎/模型时回调 (engine, model)。
34
49
  * 宿主可据此把它记成下次新建任务的默认值(移动端「我的 → 默认引擎/模型」);
35
50
  * 只点左列引擎浏览模型不回调。引擎清单探测到记住的引擎已不可用而回退时也回调。
51
+ * team: 可选。初始是否开启团队模式(默认 false)。
52
+ * onTeamChange: 可选。(on) => void。用户在**组件自己的**团队面板里改了开关时回调。
53
+ * 为什么需要它:开关的权威状态在宿主(移动端**按会话**落 localStorage,
54
+ * 一进一出要还原),而面板是组件画的 —— 不回传的话只有组件知道
55
+ * 用户开过,宿主那侧一重挂载就丢(实测:开了、跑完、重新打开会话又变回关)。
56
+ * 初始值仍由 `team` 给,两边不会互相纠正。
57
+ * features: 可选。core 能力位清单(`feature.*`),数组或 () => 数组。缺 = 当作不支持。
58
+ * teamConfig: 可选。() => {routes:{plan|text|code|image|video|game:{engine,model}}, parallel}。
59
+ * **由宿主持有**(引擎/模型是宿主的偏好,不是这一轮对话的状态):
60
+ * 组件只在每轮下发时读一次。不传 = 只传 `team:{}`,路由全走 core 默认。
61
+ * 组件不判断「有没有配置图像引擎」这类事 —— 那是 core 的裁决(缺了就明确失败)。
36
62
  * });
37
63
  * conv.send('…'); conv.interrupt(); conv.stop(); conv.answer('…');
38
64
  * conv.setSession(sid) / conv.newSession() / conv.refreshEngines() / conv.destroy();
@@ -68,6 +94,9 @@
68
94
  '<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
95
  '<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>' +
70
96
  '<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>' +
97
+ /* 「停止」用实心圆角方块,不用时钟:时钟读起来是「定时 / 等待」,
98
+ 而这一枚按下去是**立刻掐断**(用户 2026-10-22:「提交后按钮需要变成停止样式」)。 */
99
+ '<symbol id="i-stop" viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2.6"/></symbol>' +
71
100
  '<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>' +
72
101
  '<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
102
  '<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>' +
@@ -95,90 +124,179 @@
95
124
  三处用同一份图形:读历史的居中 loading、App 启动闪屏、以后别的等待态。
96
125
  ⚠️ 只在这里画一次瓶身,别在宿主页里另抄一份 —— 抄了就会像历史上那三份对话实现一样漂移。
97
126
  实现要点:
98
- · 瓶型取**瓷瓶**(细颈 · 丰腹 · 小圈足),不是烧瓶:这是「掌天瓶」这个名字的形,
99
- 圆烧瓶那种造型一眼读成化学实验,创意就落到了错的地方。
100
- · 用 SVG 而不是 div/CSS:液面要做**波浪**,CSS 做不了曲线;viewBox 固定 96×120,
101
- 调用方给 width/height 就能任意缩放,瓶身不会变形。
127
+ · 用 SVG 而不是 div/CSS:液面要做**曲线**(凹液面),CSS 做不了;viewBox 固定 64×104,
128
+ 调用方给 width/height 任意缩放,瓶身不会变形。
102
129
  · 绿液用 `<clipPath>` 剪在**瓶腔以内** —— 液面涨到瓶颈时自然收窄,
103
130
  这是「往瓶里灌」而不是「给瓶子刷绿漆」的分界。
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';
131
+ · 所有动效只动 transform / opacity:回放大日志会占死主线程(见 histLoading 那段注释)。
132
+
133
+ ── 形制:参照《凡人修仙传》的小绿瓶(掌天瓶的原型)──
134
+ 2026-10-21 用户:「瓶子太大 参考一下凡人修仙传的小绿瓶」。这次不只是改比例,是**换材质**:
135
+ ① 小口 · 短颈 · 溜肩 · 饱腹 · 圆底。腹宽/全高 ≈ 0.63(上一版 0.72 偏瘦长,像药水瓶)。
136
+ ② 它是**玉**,不是玻璃。玉「自己就有颜色」—— 所以不能画成「透明壳 + 绿汤」,
137
+ 那样看着像化学烧瓶。做法:瓶体整体是淡翠玉色(**空处也是绿的**,只是薄、透),
138
+ 液位以下才是浓翠(玉被灵液浸透 → 透光率掉下来,颜色沉下去)。
139
+ ③ 液位那条线是全图最亮的一道:玉/液的折射率差在界面上最明显,不画这条就没有「装了东西」。
140
+ ④ 尺寸要**小**:loading 里 88×143、闪屏里 104×169(原来 132×175 / 150×199)。
141
+ 它是「一枚小瓶」,不是一尊摆件;占满一屏反而不像掌天瓶了。
142
+
143
+ ── 前几版的教训(别再往回走)──
144
+ · 长颈 + 凹肩 + 尖底 = 化学烧瓶。颈占全高 >1/6 就完了(现在是 14.6..23.4,占 8.5%)。
145
+ · 肩必须**凸**(凹肩是漏斗和试管的分界);腹要接近正圆;底不能收成半圆(那是烧瓶)。
146
+ · 别画外翻的撇口:1.15px 描边下翻唇只会变成颈侧两个小耳朵(试过)。
147
+ · 亮/暗两套底都要试:玉体的 alpha 在浅底上会被冲成白瓷(实测 .42 白、.62 才读得出翠)。
148
+ 所以玉体浓度走 `--q-bottle-jade*` 令牌,宿主按主题给。
149
+ ⚠️ 轮廓是用 Catmull-Rom 由 15 个右半控制点算出来的三次贝塞尔(见 BOTTLE_PROFILE),
150
+ 不是手写路径 —— 手调控制点必然在肩/腹出现折角,看着像塑料件。改形去改那串点,
151
+ 别去改 BOTTLE_CAVITY 的 d。 */
152
+ var BOTTLE_VB = { w: 64, h: 104, x: 16, y: 4 };
153
+ /* 右半轮廓(x 以 48 为中轴):颈口 → 颈 → 肩 → 腹 → 足 → 底心 */
154
+ var BOTTLE_PROFILE = [
155
+ [52.4, 14.6], [52.4, 19.4], [53, 23.4], [56, 27.4],
156
+ [61.5, 33.6], [67.6, 41.4], [72.6, 50.6], [75.8, 60],
157
+ [77.4, 69.4], [76.6, 78.4], [73.2, 87], [67.6, 93.8],
158
+ [60.6, 98.8], [53.4, 101.8], [48, 102.6]
159
+ ];
160
+ /* 镜像 + 拼成闭合瓶腔。左右两半用**同一串点**,所以形状天然对称 —— 手写 d 做不到。 */
161
+ var BOTTLE_CAVITY = (function () {
162
+ var r2 = function (n) { return Math.round(n * 100) / 100; };
163
+ /* 端点外推一个虚拟点,首尾那两段才不会打卷 */
164
+ function segs(pts) {
165
+ var p = [pts[0]].concat(pts, [pts[pts.length - 1]]);
166
+ var out = [];
167
+ for (var i = 1; i < p.length - 2; i++) {
168
+ var a = p[i - 1], b = p[i], c = p[i + 1], d = p[i + 2];
169
+ out.push([b[0] + (c[0] - a[0]) / 6, b[1] + (c[1] - a[1]) / 6,
170
+ c[0] - (d[0] - a[0]) / 6, c[1] - (d[1] - a[1]) / 6, c[0], c[1]]);
171
+ }
172
+ return out;
173
+ }
174
+ function run(pts) {
175
+ var d = '';
176
+ segs(pts).forEach(function (s) {
177
+ d += ' C' + r2(s[0]) + ' ' + r2(s[1]) + ' ' + r2(s[2]) + ' ' + r2(s[3])
178
+ + ' ' + r2(s[4]) + ' ' + r2(s[5]);
179
+ });
180
+ return d;
181
+ }
182
+ var right = BOTTLE_PROFILE;
183
+ /* 左半:从底心往回走,x 翻成 96-x */
184
+ var left = [right[right.length - 1]].concat(
185
+ right.slice(0, -1).reverse().map(function (p) { return [96 - p[0], p[1]]; }));
186
+ return 'M' + right[0][0] + ' ' + right[0][1] + run(right) + run(left) + ' Z';
187
+ })();
188
+ /* 液面:中间低、贴壁微高 = 玉瓶里的**凹液面**(meniscus)。
189
+ 落差的两个拐点压在瓶壁内(x20 / x76),两端超出的部分由 clipPath 裁掉 ——
190
+ 剩下的正是「液体沿壁爬升」那一段。画成直线就成了「一块绿色积木」,这是早先最丑的地方。
191
+ ⚠️ 拐点不能放到 ±160:那样瓶内那 60px 只是曲线的平底段,看上去仍是一条直线(试过)。 */
192
+ function bottleSurf(y, amp, close) {
193
+ return 'M-120 ' + y + ' L20 ' + y
194
+ + ' C32 ' + y + ' 36 ' + (y + amp) + ' 48 ' + (y + amp)
195
+ + ' C60 ' + (y + amp) + ' 64 ' + y + ' 76 ' + y
196
+ + ' L216 ' + y + (close ? ' L216 150 L-120 150 Z' : '');
197
+ }
120
198
  /** 造一枚掌天瓶。`opts.fill` = 液面停在哪(0 = 空瓶 / 1 = 满),`opts.anim` = 'hist'(涨落循环)| 'rise'(一次灌满)。
121
199
  * @returns {SVGElement} 直接 appendChild 用。 */
122
200
  function bottleSVG(opts) {
123
201
  opts = opts || {};
124
202
  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;
203
+ var f = Math.max(0, Math.min(1, opts.fill == null ? 0.62 : opts.fill));
204
+ /* 液面中心:f=1 → y47(肩下,颈里留一截空玉,否则读成「塞满了」)· f=0 → y95(几乎见底)。
205
+ 上限取 47 而不是颈口 15:满瓶也要能看见瓶颈与肩,不然那只瓶子就读成一个绿球。 */
206
+ var y = 95 - 48 * f;
207
+ /* 越满凹得越浅:满瓶时液面在肩下,那儿的瓶腔已经窄了,凹太深会像液面塌下去 */
208
+ var amp = 2.2 - 0.7 * f;
209
+ /* 气泡只在液层够厚时才画(f 小的时候只是瓶底一层薄液,冒泡会浮出液面) */
210
+ var bub = f > 0.42
211
+ ? '<circle class="qbottle__bubble" cx="41" cy="92" r="1.9"/>'
212
+ + '<circle class="qbottle__bubble qbottle__bubble--b" cx="55" cy="95.5" r="1.4"/>'
213
+ + '<circle class="qbottle__bubble qbottle__bubble--c" cx="47" cy="97.5" r="1.1"/>'
214
+ : '';
130
215
  var svg = document.createElementNS(SVG_NS, 'svg');
131
- svg.setAttribute('viewBox', '0 0 ' + BOTTLE_VB.w + ' ' + BOTTLE_VB.h);
216
+ svg.setAttribute('viewBox', BOTTLE_VB.x + ' ' + BOTTLE_VB.y + ' ' + BOTTLE_VB.w + ' ' + BOTTLE_VB.h);
132
217
  svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
133
218
  svg.setAttribute('aria-hidden', 'true');
134
219
  svg.setAttribute('class', 'qbottle' + (opts.cls ? ' ' + opts.cls : '')
135
220
  + (opts.anim ? ' qbottle--' + opts.anim : ''));
136
- /* 波浪起伏幅度:液面越高、可见瓶腔越窄,浪要收得越小,否则会「浪出瓶外」 */
137
- var amp = 3.2 + 3.4 * (1 - f);
221
+ /* 灌满动画的位移量交给 CSS:从「贴瓶底」涨到当前位置 `y`,也就是 (95 - y)。
222
+ 硬编码在 keyframes 里的话,以后改 fill 就会露出半截液体或灌不满。
223
+ (95 = f=0 时的液面高度,和上面那行 95 - 48f 是同一个基准点。) */
224
+ svg.style.setProperty('--qb-dy', Math.round(95 - y) + 'px');
138
225
  svg.innerHTML =
139
226
  '<defs>'
140
227
  + '<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)"/>'
228
+ /* 左壁边光只画左半边(受光侧),用矩形裁一下省得另算路径 */
229
+ + '<clipPath id="' + uid + 'lf"><rect x="0" y="0" width="36" height="120"/></clipPath>'
230
+ /* 液面亮线只在中段最亮:贴壁那一段被壁面压暗,整条亮到边反而假 */
231
+ + '<clipPath id="' + uid + 'md"><rect x="20" y="0" width="56" height="120"/></clipPath>'
232
+ /* 玉体:**上薄下厚**(玉雕积料的常规做法)。alpha 走令牌,因为浅底上要更浓才不发白 */
233
+ + '<linearGradient id="' + uid + 'jd" x1=".2" y1="0" x2=".6" y2="1">'
234
+ + '<stop offset="0" stop-color="var(--q-bottle-jade-hi, rgba(120,232,175,.44))"/>'
235
+ + '<stop offset=".55" stop-color="var(--q-bottle-jade, rgba(52,192,124,.46))"/>'
236
+ + '<stop offset="1" stop-color="var(--q-bottle-jade-lo, rgba(8,96,60,.58))"/>'
146
237
  + '</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"/>'
238
+ /* 玉的体积:左壁亮、右壁暗(缺这层,瓶子就是一张剪纸) */
239
+ + '<linearGradient id="' + uid + 'vol" x1="0" y1="0" x2="1" y2="0">'
240
+ + '<stop offset="0" stop-color="#fff" stop-opacity=".13"/>'
241
+ + '<stop offset=".18" stop-color="#fff" stop-opacity=".03"/>'
242
+ + '<stop offset=".5" stop-color="#fff" stop-opacity="0"/>'
243
+ + '<stop offset=".82" stop-color="#000" stop-opacity=".18"/>'
244
+ + '<stop offset="1" stop-color="#000" stop-opacity=".34"/>'
151
245
  + '</linearGradient>'
246
+ /* 浓翠(液位以下):上亮下深,读成「灵液浸透玉胎 + 深处沉色」 */
247
+ + '<linearGradient id="' + uid + 'g" x1=".15" y1="0" x2=".45" y2="1">'
248
+ + '<stop offset="0" stop-color="var(--q-bottle-hi, #B4FFD6)"/>'
249
+ + '<stop offset=".2" stop-color="var(--q-bottle, #2ECB80)"/>'
250
+ + '<stop offset=".8" stop-color="var(--q-bottle-lo, #0A7548)"/>'
251
+ + '<stop offset="1" stop-color="var(--q-bottle-deep, #03361F)"/>'
252
+ + '</linearGradient>'
253
+ /* 瓶底聚光:光穿过灵液在圆底上聚成一团 */
254
+ + '<radialGradient id="' + uid + 'ca" cx=".5" cy=".5" r=".5">'
255
+ + '<stop offset="0" stop-color="var(--q-bottle-hi, #B4FFD6)" stop-opacity=".34"/>'
256
+ + '<stop offset="1" stop-color="var(--q-bottle-hi, #B4FFD6)" stop-opacity="0"/>'
257
+ + '</radialGradient>'
152
258
  + '</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 涨上来 ── */
259
+ /* ── ① 玉胎:整只瓶都是绿的(空处是薄玉,不是透明)── */
260
+ + '<path d="' + BOTTLE_CAVITY + '" fill="url(#' + uid + 'jd)"/>'
261
+ + '<path d="' + BOTTLE_CAVITY + '" fill="url(#' + uid + 'vol)"/>'
262
+ /* ── ② 浓翠:整块剪在瓶腔里,靠 translateY 涨上来 ── */
157
263
  + '<g clip-path="url(#' + uid + 'c)">'
158
264
  + '<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"/>'
265
+ + '<path fill="url(#' + uid + 'g)" d="' + bottleSurf(y, amp, true) + '"/>'
266
+ /* 液位那条亮线:玉/液的界面反射。它是「装了东西」的唯一证据,不能省 */
267
+ + '<path d="' + bottleSurf(y, amp) + '" fill="none"'
268
+ + ' stroke="var(--q-bottle-rim, #EAFFF2)" stroke-width=".85" opacity=".75"'
269
+ + ' stroke-linecap="round" clip-path="url(#' + uid + 'md)"/>'
170
270
  + '</g>'
271
+ /* 聚光**不跟着液体动**:光在瓶底的位置是固定的,跟着涨落会像贴纸在滑。 */
272
+ + '<ellipse cx="48" cy="95" rx="16" ry="5" fill="url(#' + uid + 'ca)"/>'
273
+ + bub
171
274
  + '</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"/>';
275
+ /* ── ③ 边光:左壁一道细亮线(玉的厚度感全靠它)+ 颈光 + 肩部短高光 ── */
276
+ + '<path d="' + BOTTLE_CAVITY + '" fill="none" stroke="var(--q-bottle-rim, #EAFFF2)"'
277
+ + ' stroke-width="1.8" opacity=".3" stroke-linecap="round" clip-path="url(#' + uid + 'lf)"/>'
278
+ + '<path d="M52.9 18.6v6.8" stroke="var(--q-bottle-rim, #EAFFF2)" stroke-width="1.3"'
279
+ + ' stroke-linecap="round" opacity=".26"/>'
280
+ + '<path d="M25.4 62c1.4-7.2 5-13.4 10.2-17.8" fill="none"'
281
+ + ' stroke="var(--q-bottle-rim, #EAFFF2)" stroke-width="1.5"'
282
+ + ' stroke-linecap="round" opacity=".24"/>'
283
+ /* ── ④ 轮廓:绿线,比玉胎深一档。别加粗(粗描边会把瓶子压回「线稿」)── */
284
+ + '<path d="' + BOTTLE_CAVITY + '" fill="none" stroke="var(--q-bottle-line, #43A06E)"'
285
+ + ' stroke-width="1.15" stroke-linejoin="round"/>'
286
+ /* ── ⑤ 小口:内壁一圈暗 + 沿口亮(平口画法,见上面「别画撇口」那条)── */
287
+ + '<ellipse cx="48" cy="15.6" rx="4.9" ry="1.6" opacity=".75"'
288
+ + ' fill="var(--q-bottle-deep, #03361F)"/>'
289
+ + '<ellipse cx="48" cy="15.3" rx="4.9" ry="1.6" fill="none"'
290
+ + ' stroke="var(--q-bottle-rim, #EAFFF2)" stroke-width=".75" opacity=".55"/>'
291
+ /* ── ⑥ 小木塞:比口沿略宽,只在瓶口露很短一截(小绿瓶的塞子本来就很含蓄)──
292
+ ⚠️ 兜底色必须写死成木色,不能给 `currentColor`:SVG 的 color 继承自
293
+ `.qbottle{color:--q-bottle-line}`(绿),宿主没定义 --q-bottle-cork 时
294
+ 塞子就会变成一块绿塑料(试过)。 */
295
+ + '<path d="M42.4 6.8h11.2a2.2 2.2 0 0 1 2.2 2.2v3.2c0 1.2-1.1 2.3-2.6 2.3h-10.4'
296
+ + 'c-1.4 0-2.6-1-2.6-2.3V9a2.2 2.2 0 0 1 2.2-2.2z"'
297
+ + ' fill="var(--q-bottle-cork, #7A6650)"/>'
298
+ + '<path d="M42.4 6.8h11.2a2.2 2.2 0 0 1 2.2 2.2v1H40.2V9a2.2 2.2 0 0 1 2.2-2.2z"'
299
+ + ' fill="var(--q-bottle-rim, #EAFFF2)" opacity=".2"/>';
182
300
  return svg;
183
301
  }
184
302
  var bottleSeq = 0;
@@ -229,6 +347,15 @@
229
347
  if (text != null) n.textContent = text;
230
348
  return n;
231
349
  }
350
+ /* 选择器里的「一行模型」:左名字、右倍率(`×0.34`)。
351
+ 两列布局用 CSS 的 `.pick__cr` 定宽靠右,名字过长只自己省略 —— 倍率列不跟着抖。
352
+ 倍率为空(引擎不给数据)时只画名字,不占位、不显示「×1」这类编出来的值。 */
353
+ function modelRow(id, credit) {
354
+ var row = el('span', 'pick__crow');
355
+ row.appendChild(el('span', 'pick__cl', qaModelText(id)));
356
+ if (credit) row.appendChild(el('span', 'pick__cr', credit));
357
+ return row;
358
+ }
232
359
  function svgIcon(id, size) {
233
360
  var s = document.createElementNS(SVG_NS, 'svg');
234
361
  s.setAttribute('width', size || 16); s.setAttribute('height', size || 16);
@@ -247,12 +374,40 @@
247
374
  var state = {
248
375
  engines: opts.engines || [], engine: opts.engine || '', model: opts.model || '',
249
376
  permission: opts.permission || '', workspace: opts.workspace || '', attachments: [], session: opts.session || '', busy: false, askId: null,
377
+ /* 这一轮选中的插件(2026-10-21 用户:「选择插件后 对话框中要出现一个胶囊
378
+ 然后发送后就是提示词变成要使用这个插件做什么」)。
379
+ 存的是 GET /desk/plugins 那一行**原样**(id/name/cli/usePrompt…),
380
+ 不是只存 id —— 发送时要当场用它拼提示词,再回去查一次既慢又可能已变。
381
+ 它跟着一轮走:send 时被读走并清空(见 takePlugin)。 */
382
+ plugin: null,
383
+ /* 运行中提交的追问(2026-10-21 用户:「会话中运行中允许先提交追问 类似 magic-test
384
+ pc 端相关」):busy 时提交**不打断**当前轮,先排在这里,等这一轮收尾后自动发出。
385
+ 条目 = `{text, atts, attPaths}`;atts 是气泡缩略图的快照(remoteUrl),
386
+ attPaths 是本轮要带给 core 的本地绝对路径。归属当前这条会话 ——
387
+ 换会话 / 新建会话时整队作废(旧会话的追问不能画进新会话的画布)。 */
388
+ queue: [],
389
+ /* 团队模式(2026-10-21):开关是**这一轮怎么跑**,不是这台机器的属性 ——
390
+ 所以它在这条会话的状态里,每轮随 payload.team 下发(core 侧无状态)。
391
+ 宿主**按会话**存在自己的 localStorage 里,通过 team / setTeam / teamConfig 交给组件。 */
392
+ team: !!opts.team,
393
+ /* 本轮 SSE 的取消钩子(transport.ask 的返回值)。一轮真正收尾时用它把**还挂着的那条流**掐掉:
394
+ 否则 keep-alive 的 core 进程不退出、never 发 `end` 帧,runTurn 的 finally 就一直等不来。 */
395
+ askHandle: null,
250
396
  renderedSeq: {}, agentRaw: '', replay: false,
251
397
  /* 本轮已收到的最大 seq(**流 seq,每轮从 1 重来**,见 render 的 seq 去重注释)。
252
398
  断线后拿它当游标调 /desk/ask/{id}/events?after= 增量续传。 */
253
399
  lastSeq: 0,
254
400
  /* 断线续传轮询的取消钩子(换会话 / 开新轮时调用,避免旧轮的事件落进新画面) */
255
401
  resumeStop: null,
402
+ /* 打开会话时宿主给的那行清单数据(setSession 的第二参,字段同 core 的 --sessions:
403
+ status / state / alive / engine / model / title)。`status`(会话级,唯一口径)与
404
+ `alive` 用来判断这条会话是不是「登记表上还在跑」—— 在跑的才挂 follow(见 startFollow)。 */
405
+ meta: null,
406
+ /* 跨进程进度尾随:`{sid, cancel, askId}`,见 startFollow / stopFollow。null = 没在跟。 */
407
+ follow: null,
408
+ /* 上一次 follow 跟到终态的时间:`{sid, at}`。停留期巡检(watchTick)据此不重拉 ——
409
+ core 刚退出时 /desk/asks 里那个 run 还在保留期内,否则会整屏重画一遍。 */
410
+ followDone: null,
256
411
  working: null, autoFollow: true, lastPrompt: '', lastAttPaths: [],
257
412
  /* 这一轮的渲染句柄(内核给的 h + 它的工具 / 委派登记表 + 起点),见 qaEnsureAnswer */
258
413
  qa: null,
@@ -277,7 +432,11 @@
277
432
  /* 引擎停在一问上等答案(见 MagicHost.send:这种时候作答要走控制通道,不能当新的一轮发) */
278
433
  awaitingAnswer: false,
279
434
  /* 内核「重试」走 MagicHost.send(…, {echo:false}):那条气泡已经在画布上了,别再画一遍 */
280
- noEcho: false
435
+ noEcho: false,
436
+ /* 最近一条**已画到画布上**的提问(签名 = 正文)。同一条提问会从三处再走一遍:
437
+ 发送时的本地回声 / 历史日志回放 / 网关在 ask 缓冲头合成的 `type:"user"`(seq 固定 0)。
438
+ 没有这道签名,一次提问可能画出两三个一模一样的气泡(2026-10-20 用户报「出来三个」)。 */
439
+ lastUserSig: ''
281
440
  };
282
441
 
283
442
  /* ══════════ markdown 渲染(**栈归内核**)══════════
@@ -495,16 +654,30 @@
495
654
  (function installDeskProbe() {
496
655
  if (!transport) return;
497
656
  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
- }
657
+ /* ⚠️ 宿主可能已经把 `window.desk` **冻结**了(桌面壳 preload 注入的是
658
+ `Object.freeze` 过的桥,且里面未必有 mediaUrl)—— 直接往上面写会抛
659
+ 「Cannot add property mediaUrl, object is not extensible」,把整个对话组件
660
+ 拖挂不上去(2026-10-22 移动端在壳里打开就是这个哑故障)。
661
+ 所以:能写就写;写不进去的挪到 `window.__qaDeskExtra`(内核按同名回落)。 */
662
+ var extra = null;
663
+ if (!d || typeof d !== 'object') {
664
+ try { d = window.desk = {}; } catch (e) { d = null; }
665
+ }
666
+ function put(name, fn) {
667
+ if (!fn) return;
668
+ if (d && typeof d[name] === 'function') return; // 宿主已有,链路更短
669
+ if (d) {
670
+ try { d[name] = fn; return; } catch (e) { /* 冻结/只读 → 走 extra */ }
671
+ }
672
+ extra = extra || (window.__qaDeskExtra = window.__qaDeskExtra || {});
673
+ if (typeof extra[name] !== 'function') extra[name] = fn;
674
+ }
675
+ put('pathProbe', typeof transport.pathProbe === 'function'
676
+ ? function (req) { return transport.pathProbe(req); } : null);
677
+ put('mediaUrl', typeof transport.fileUrl === 'function'
678
+ ? function (p) { return transport.fileUrl(p); } : null);
679
+ put('pathRead', typeof transport.pathRead === 'function'
680
+ ? function (req) { return transport.pathRead(req); } : null);
508
681
  })();
509
682
 
510
683
  /* 本轮产物:与 PC 工作台 / magic-test 用**同一套**(内核 qaTurnArtsTrack,见 19-attachments.js)——
@@ -512,10 +685,31 @@
512
685
  不接这一下,回放与实时都只有正文里写出来的那些图,没写进正文的产物一张不显示。 */
513
686
  var turnArts = (typeof qaTurnArtsTrack === 'function') ? qaTurnArtsTrack() : null;
514
687
 
688
+ /** 这一轮的渲染句柄里**已经有内容**了吗(正文 / 思考 / 工具 / 审批 / 委派 / 产物)。
689
+ * 空终态判定与「撤掉空助手块」共用这一份口径。 */
690
+ function turnHasContent(h) {
691
+ var el = h && h.el;
692
+ if (!el) return false;
693
+ return !!el.querySelector('.qa-t, .qa-think, .qa-tools, .qa-tool, .qa-ask, .qa-agent, .qa-mk, .qa-sep, .qa-img');
694
+ }
695
+ /** 只有「引擎抬头」的助手块就撤掉(否则收尾行上方留一块空白,2026-10-20 用户截图)。 */
696
+ function dropEmptyAnswer(h) {
697
+ var el = h && h.el;
698
+ if (!el || turnHasContent(h)) return;
699
+ if (el.parentNode) el.parentNode.removeChild(el);
700
+ }
701
+
515
702
  function kernelApply(ev) {
516
703
  if (!ev || typeof ev !== 'object') return;
517
704
  var t = ev.type || ev.kind;
518
705
  if (!t) return; // 畸形帧(连类型都没有)
706
+ /* ⚠️ 空终态(`turn_end` 无正文、无 item_id)**不是**这一轮结束(2026-10-20 用户
707
+ 「发一个好了吗 出来三个 而且没回复」):引擎的 result 信封偶尔先来一条空行
708
+ (internal/agent/streamjson.go 无条件 emitTurnEnd),1.8s 就报「已完成」、
709
+ 掐断 SSE,10s 后真正的正文(seq 3…219)全被 qaEnsureAnswer 的 qaClosed 丢掉。
710
+ 这里直接不喂内核 —— 不落收尾行、不置 qaClosed、不 cancel 流;真空白轮由 core
711
+ 进程退出时的 `end` 那条兜底收尾。 */
712
+ if (t === 'turn_end' && !String(ev.text == null ? '' : ev.text).trim() && !ev.item_id && !turnHasContent(state.qa && state.qa.h)) return;
519
713
  var e = ev.type ? ev : Object.assign({}, ev, { type: t });
520
714
  if (t === 'start') {
521
715
  /* 一轮真正用的引擎 / 模型(配置里的模型链会自动降级)→ 选择器跟着改口 */
@@ -535,6 +729,12 @@
535
729
  2026-10-17 用户报:「移动端看不到会话历史」—— 症状是历史里的助手那一侧画出来了、
536
730
  用户那句提问**一个字都没有**(问到哪了全靠猜)。 */
537
731
  if (t === 'user') {
732
+ /* 同一条提问已经在画布上了 → 不再画第二个气泡。来源有三处(见 state.lastUserSig),
733
+ 典型叠加:发送时本地回声画一次 → 看门狗清屏重放又画一次 → 续传补拉的合成 user 再画一次。
734
+ 回放不走判重(历史里同样的提问本来就该各画一条,且回放是整屏重建)。 */
735
+ var usig = String(e.text == null ? '' : e.text).trim();
736
+ if (usig && !state.replay && usig === state.lastUserSig) return;
737
+ if (usig) state.lastUserSig = usig;
538
738
  /* 历史回放里「用户那句」就是一轮的新起点:上一轮的已收尾标记到此作废,
539
739
  否则第二轮的助手消息会被 qaEnsureAnswer 拒开(见 state.qaClosed 注释)。 */
540
740
  state.qaClosed = false;
@@ -586,6 +786,8 @@
586
786
  if (q.h.el) { q.h.el.__qaReplay = true; q.h.el.__qaMs = replaySpan(); }
587
787
  /* 先补挂「本轮产物」再封口(在 qaRunEnd 拆卡之前 —— 与 27-conv-ledger 的 finish 同序)。 */
588
788
  if (turnArts) { try { turnArts.flush(q.h); } catch (e0) {} }
789
+ /* 这一轮在日志里一条内容都没有(只有抬头)→ 别把空块留在历史里。 */
790
+ dropEmptyAnswer(q.h);
589
791
  /* qaRunEnd = finish + 摘掉「生成中」胶囊(内核的统一收口,见 03-chat-message)。
590
792
  这里只 finish 会把那枚胶囊永久钉在画布上(回放里已经真实出现过)。 */
591
793
  try { if (typeof qaRunEnd === 'function') qaRunEnd(q.h); else if (q.h.finish) q.h.finish(); }
@@ -655,11 +857,37 @@
655
857
  var ms = q.ms || (state.replay ? replaySpan() : Date.now() - (state.resumeT0 || q.t0));
656
858
  /* 收尾行落下之前补挂「本轮产物」(这一步只追加节点,不影响收尾行的位置)。 */
657
859
  if (turnArts) { try { turnArts.flush(q.h); } catch (e0) {} }
860
+ /* 一句正文/过程都没收到的助手块(只剩引擎抬头)就撤掉 —— 否则收尾行上方留一块空白
861
+ (2026-10-20 用户截图:空块 + 「已完成 · 总耗时 1.8s」)。收尾行本身挂在线程容器上,
862
+ 不在这条消息块里,所以撤掉不丢收尾行。 */
863
+ dropEmptyAnswer(q.h);
658
864
  qaRenderDone(ok, label, thread(), ms);
659
865
  if (opts.onBusy) { try { opts.onBusy(false); } catch (err) {} }
866
+ /* 收掉运行态(2026-10-20 用户:「进行中已经变了 结束的时候图没变」)。
867
+ 这里是「这一轮真的结束了」的**唯一收口**(turn_end / error / end 都落到这里),
868
+ 而运行态过去只由 runTurn 的 finally 复位 —— 它要等 transport.ask 的 SSE `end` 帧,
869
+ 可那帧是 **core 进程退出时**才发的(keep-alive 空闲窗口内不会来),
870
+ 于是绿液瓶与绿光边框会一直挂着。所以在这里当场复位:
871
+ 胶囊切回引擎图标、发送键切回「发送」、busy 放开。
872
+ ⚠️ 回放不走(历史里的 turn_end 不改变当前运行态;否则正在看别人跑的会话会被误判成空闲)。
873
+ ⚠️ 幂等:runTurn 的 finally 稍后还会再跑一遍,重复执行无害。 */
874
+ if (!state.replay) {
875
+ clearWorking();
876
+ state.busy = false;
877
+ state.askId = null;
878
+ setSendMode('idle');
879
+ /* 顺手把还挂着的那条流掐掉:让 runTurn 的 Promise 收口(否则它会一直等 end 帧)。
880
+ 取消走 transport 的 aborted 分支,不会弹「发送失败」。 */
881
+ var ah = state.askHandle;
882
+ if (ah) { state.askHandle = null; try { if (ah.cancel) ah.cancel(); } catch (e2) {} }
883
+ /* 这一轮收尾 = 排队追问可以发了(PC 的 qaStopActive 也是在这里 qaMsgFlush)。
884
+ 延到下一拍:runTurn 的 finally 还等着这条流的 Promise 收口。 */
885
+ scheduleFlush();
886
+ }
660
887
  }
661
888
 
662
889
  function clearThread() {
890
+ histSid = null; // 画布清空 = 历史不在场了(见 setSession 的短路)
663
891
  /* 换会话先停掉上一轮的断线续传轮询:否则补拉回来的事件会落进新会话的画面。 */
664
892
  if (state.resumeStop) { var rs = state.resumeStop; state.resumeStop = null; try { rs(); } catch (e) {} }
665
893
  thread().innerHTML = '';
@@ -674,10 +902,17 @@
674
902
  if (typeof qaScrollReset === 'function') { try { qaScrollReset(); } catch (e) {} }
675
903
  state.agentRaw = '';
676
904
  state.lastSeq = 0;
905
+ /* 画布清空 ⇒ seq 判重表一并作废:表里记的是**上一段画面**见过的 seq,
906
+ 而新会话/新一段的 follow 事件走的是同一条判重路径(seq 从 1 起、跨会话重号),
907
+ 留着它会把新会话开头的事件当成「已画过」静默丢掉。
908
+ ⚠️ 2026-10-21:改成 follow 优先之后这条才成为真问题 —— 以前首载走 ask 缓冲
909
+ (那条路径自己会清表),现在 replay 之后的增量直接进判重。 */
910
+ state.renderedSeq = {};
677
911
  /* 换会话:这一轮的渲染句柄也归零(否则下一条事件会落进**上一个会话**的那条消息里) */
678
912
  if (turnArts) { try { turnArts.flush(null); } catch (e0) {} } // 上一会话残留的产物路径不带走
679
913
  state.qa = null;
680
914
  state.qaClosed = false;
915
+ state.lastUserSig = ''; // 画布已清空:提问判重签名一并作废
681
916
  state.replayT0 = 0; state.replayLastAt = 0;
682
917
  state.resumeT0 = 0;
683
918
  state.awaitingAnswer = false;
@@ -693,9 +928,19 @@
693
928
  /* 控制通道:打断 / 收工 / 回答案。op 的语义与 core 的 --control 同协议;
694
929
  具体怎么送到 core 由 transport.control 决定(HTTP / IPC / CLI 都可能)。
695
930
  ⚠️ 没有 askId 就如实说,不要静默失败 —— 一个按下去没反应的按钮比报错更难查。
696
- 返回 Promise<boolean>,给审批卡判断「答没答上」用。 */
931
+ 返回 Promise<boolean>,给审批卡判断「答没答上」用。
932
+ ⚠️ 例外:这一轮**还在宿主那里排队**(beforeAsk 没 resolve)时本来就没有 askId,
933
+ 此时打断的真实含义是「别发这一轮了」。宿主用 opts.onInterrupt 认领这层语义:
934
+ 返回 true = 它已经把排队撤掉,组件不再往下走 control。 */
697
935
  async function control(op, text) {
698
936
  if (!state.askId) {
937
+ if (op === 'interrupt' && typeof opts.onInterrupt === 'function') {
938
+ var owned = false;
939
+ try { owned = opts.onInterrupt(state.lastPrompt || ''); } catch (e) { owned = false; }
940
+ /* 不留提示:宿主撤掉排队后 beforeAsk 会 resolve 成 false,runTurn 那条分支
941
+ 已经会说「已取消排队(串行模式)」—— 这里再说一遍就是两条一样的。 */
942
+ if (owned) return true;
943
+ }
699
944
  noticeItem('这一轮还没拿到轮次 id,稍等 open 事件回来再试');
700
945
  return false;
701
946
  }
@@ -761,35 +1006,305 @@
761
1006
  }
762
1007
 
763
1008
  async function send() {
764
- var text = ctx.input.value.trim();
765
- if (!text || state.busy) return;
1009
+ /* 插件胶囊在场时,用户写的只是「做什么」;**真正下发的提示词**由这一层补上
1010
+ 「用哪个 CLI 做」(见 buildPluginPrompt)。所以这里读的是原文,
1011
+ 两个都空才拦(只有插件、没写要求时用插件的模板兜住)。 */
1012
+ var typed = ctx.input.value.trim();
1013
+ var plug = state.plugin;
1014
+ var attsAll = state.attachments || [];
1015
+ /* 只贴图不写字也允许发送(粘贴 / 拖入截图后直接回车是很自然的动作):
1016
+ 附件条上有图就算「有内容」;正文由下面补一句中性引导(与 PC 端 qaSend 的
1017
+ `请看截图。` 同口径 —— 空 prompt 会被服务端当场拒掉)。 */
1018
+ if (!typed && !plug && !attsAll.length) return;
766
1019
  if (!state.engine) { noticeItem('还没有可用引擎:请先选一个引擎'); return; }
767
- if ((state.attachments || []).some(function (a) { return a.uploading; })) { noticeItem('附件还在上传,请稍候'); return; }
768
- state.lastPrompt = text;
1020
+ /* 上传没完成 / 失败都不许发:半途发出去,引擎拿到的是空路径或不存在的路径,
1021
+ 而报错出现在下游、指向别处 —— 这类失败最难排查(同 PC 端 qaSend 的附件闸门)。 */
1022
+ if (attsAll.some(function (a) { return a.uploading; })) { noticeItem('附件还在上传,请稍候'); return; }
1023
+ if (attsAll.some(function (a) { return a.error; })) { noticeItem('有附件没上传成功,先移除再发'); return; }
1024
+ var text = plug ? buildPluginPrompt(typed, plug) : typed;
1025
+ if (!text && attsAll.length) text = '请看截图。';
769
1026
  /* 发出前先把附件快照下来:气泡缩略图走**回读地址**(不用 objectURL —— 发送后
770
1027
  clearAttachments 会 revoke,气泡若依赖它们会当场裂图);本轮的附件路径也一并带走。 */
771
1028
  var sent = (state.attachments || []).filter(function (a) { return a.path; });
772
1029
  var sentAtts = sent.map(function (a) { return { name: a.name, remoteUrl: a.remoteUrl }; });
773
1030
  var attPaths = sent.map(function (a) { return a.path; });
774
- state.lastAttPaths = attPaths; // 记住本轮附件,重试时一起重发
1031
+ /* 输入框与附件条当场清空(不论这一轮是立刻发还是排队):
1032
+ 用户要能马上接着写第二条,不必等上一轮跑完。 */
1033
+ ctx.input.value = ''; autoGrow();
1034
+ /* 插件胶囊随本轮带走:已经写进 text 了,留着会让下一轮悄悄又用它 ——
1035
+ 用户下一句可能只是追问,不该再被套一层「用 xxx 完成」。 */
1036
+ if (plug) setPlugin(null);
1037
+ clearAttachments();
1038
+ /* 运行中提交 = **排队**,不打断当前轮(2026-10-21 用户:「会话中运行中允许先提交
1039
+ 追问 类似 magic-test pc 端相关」)。与 PC 工作台同一语义(07-chat-stream-render.js
1040
+ 的 qaMsgEnqueue:busy + 有正文 → 排进队列,收尾时 qaMsgFlush 自动提交);
1041
+ 只有「忙 + 空输入」点发送键才是停止,见下面 sendBtn 的 click 分支。 */
1042
+ /* `state.queue.length` 也算「忙」:flush 已经在路上的那一小段窗口里,
1043
+ 新提交的必须排到队尾,不能插到前一条排队追问的前面。 */
1044
+ if (state.busy || state.queue.length) {
1045
+ /* ⚠️ 气泡**当场就画**,不等轮到它
1046
+ (2026-10-21 用户:「确实合并了 但是追问的内容没在对话内容区域显示」)。
1047
+ 以前这里只往排队条里塞一条 chip:用户写完一抬头,对话区里空空的,
1048
+ 自己那句话只在输入框上方那行小字里 —— 看上去就像没发出去。
1049
+ 串行门那条路径(beforeAsk 挂起)反而是先画气泡再等,两边观感不一致。
1050
+ 现在两条路一致:**发出去的那一刻就落进画布**,排队状态由气泡自己标。 */
1051
+ var qb = qaRenderMine(text, sentAtts || []);
1052
+ if (qb) qb.classList.add('is-queued');
1053
+ var item = { id: qid(), text: text, atts: sentAtts, attPaths: attPaths, bubble: qb };
1054
+ state.queue.push(item);
1055
+ /* 取消入口做在**气泡上**(排队条那边不再重复列一遍,见 renderQueue):
1056
+ 用户要撤的就是这一句话,按钮贴在它身上最直接。 */
1057
+ if (qb) item.unmountCancel = mountQueueCancel(qb, item);
1058
+ /* 气泡已经画过了,轮到它时别再画一遍。 */
1059
+ state.noEcho = false;
1060
+ renderQueue();
1061
+ /* 这条队列只在内存里,刷新/关 App 就整条消失(2026-10-21 用户:
1062
+ 「非团队的串行排队也没落库」)。交给宿主记进它那套持久化队列
1063
+ (移动端复用串行门的 magic.serial.queue.v1,重开 App 由宿主重发);
1064
+ 宿主不给钩子就维持原来「只在内存排队」的行为。 */
1065
+ notifyQueue('push', item);
1066
+ setSendMode('busy'); // 输入已空 → 发送键切回「停止」
1067
+ return;
1068
+ }
1069
+ await fireTurn(text, sentAtts, attPaths);
1070
+ }
1071
+
1072
+ /* 这一轮在排队期间被取消:把画布上那条气泡标成「已取消」。
1073
+ 不直接删 —— 用户刚写完就消失,会让人怀疑自己是不是根本没发出去;
1074
+ 留一条带说明的气泡,等于给这次操作留个回执。 */
1075
+ function markLastMineCancelled() {
1076
+ var s = document.getElementById('qa-stream');
1077
+ if (!s) return;
1078
+ var mines = s.querySelectorAll('.qa-msg.qa-mine');
1079
+ var last = mines[mines.length - 1];
1080
+ if (!last) return;
1081
+ last.classList.remove('is-queued');
1082
+ last.classList.add('is-cancelled');
1083
+ }
1084
+
1085
+ /* 真正开跑一轮:本地用户气泡(内核的 qaRenderMine)+ 附件快照 + runTurn。
1086
+ 运行中排队的那条(flushQueue)也走这里 —— 两条路径画出来必须一模一样。
1087
+ preBubble:这条气泡**已经画过**(排队时当场画的,见 send 的排队分支)——
1088
+ 传进来的话就只把它从「等待中」恢复成正常态,不再重画。 */
1089
+ async function fireTurn(text, sentAtts, attPaths, preBubble) {
1090
+ state.lastPrompt = text;
1091
+ state.lastAttPaths = attPaths || []; // 记住本轮附件,重试时一起重发
775
1092
  /* 用户那条气泡由**内核**画(qaRenderMine)—— 与另外两端逐字同一份实现;
776
1093
  随消息发出的截图也一并交给它(第二个参数是附件清单)。
777
1094
  ⚠️ 内核的「重试」走 MagicHost.send(…, {echo:false}):那条气泡本来就在画布上,
778
1095
  再画一次会有两条一模一样的问题(见上面挂桥那一段)。 */
779
- if (!state.noEcho) qaRenderMine(text, sentAtts);
1096
+ var bubble = preBubble || null;
1097
+ if (preBubble) {
1098
+ try { preBubble.classList.remove('is-queued'); } catch (e) {}
1099
+ } else if (!state.noEcho) bubble = qaRenderMine(text, sentAtts || []) || null;
780
1100
  state.noEcho = false;
781
- ctx.input.value = ''; autoGrow();
782
- clearAttachments(); // 附件随本轮带走,输入框立刻清空(不等 runTurn 结束)
783
- await runTurn(text, attPaths);
1101
+ /* 本地回声也登记签名:同一条提问随后若从补拉缓冲(网关合成的 `type:"user"`)再来一遍,
1102
+ 或者看门狗清屏重放,就不再画出第二个一样的气泡(见 state.lastUserSig)。 */
1103
+ state.lastUserSig = text;
1104
+ await runTurn(text, attPaths, bubble);
1105
+ }
1106
+
1107
+ /* ── 排队追问:渲染 / 取消 / 收尾后自动提交 ──
1108
+ 语义照 PC 的 qaMsgQueue / qaMsgFlush:一条一轮,串行;flush 只在
1109
+ 「没有正在跑的轮次」时发生(state.busy === false),所以不会插进当前流里。
1110
+ 触发点有三处(都是「这一轮真的结束了」):kernelDone / runTurn 的 finally /
1111
+ followEnd(尾随别人跑的会话结束时)。flushing 标记保证不会双发。 */
1112
+ var flushing = false;
1113
+
1114
+ /* 排队条目的身份:宿主据此把「进队 / 轮到 / 被撤」这三件事对应到它自己那条
1115
+ 持久化记录上(见 notifyQueue)。只在本组件内唯一即可,不必跨端可读。 */
1116
+ function qid() { return 'q' + Date.now() + '-' + Math.floor(Math.random() * 1000); }
1117
+
1118
+ /* 把排队条目的变化告诉宿主(可选钩子):
1119
+ · push:刚进队(此时还没下发给 core,宿主要能把它写进磁盘/列表)
1120
+ · take:轮到它了,正要真正下发(宿主该把那条「等待中」删掉)
1121
+ · drop:用户取消了(宿主该把它从磁盘上删掉)
1122
+ 为什么不在这里自己写 localStorage:设备身份(令牌)/空间归属是宿主的,
1123
+ 同一个浏览器里可能配对过好几台桌面,键与隔离口径只有宿主知道。
1124
+ PC 宿主不接这三个钩子 → 保持原样(纯内存排队,收尾时自动 flush)。 */
1125
+ function notifyQueue(kind, it) {
1126
+ var name = kind === 'push' ? 'onQueuePush' : kind === 'take' ? 'onQueueTake' : 'onQueueDrop';
1127
+ var fn = opts[name];
1128
+ if (typeof fn !== 'function' || !it || !it.id) return;
1129
+ var payload = it.id;
1130
+ if (kind === 'push') {
1131
+ /* 字段与 beforeAsk 的 info 对齐:宿主拿同一套排队逻辑落库、重启后原样重发
1132
+ (少了 session 追问会变成新会话,少了 team 会静默降级成单任务)。 */
1133
+ payload = {
1134
+ id: it.id, prompt: it.text, attachments: (it.attPaths || []).slice(),
1135
+ session: state.session || '', workspace: state.workspace || '',
1136
+ engine: state.engine, model: state.model, permission: state.permission,
1137
+ team: state.team ? teamPayload() : null
1138
+ };
1139
+ }
1140
+ try { fn(payload); } catch (e) {}
1141
+ }
1142
+
1143
+ /* 排队条:**只在气泡画不出来时**兜底。
1144
+ 正常路径下排队的那句已经作为气泡落在画布上了(见 send 的排队分支),
1145
+ 再在输入框上方原样列一遍就是同一句话出现两次 —— 用户还得在两处找「取消在哪」。
1146
+ 所以现在的分工是:
1147
+ · 气泡(首选):一句「排队中 · 上一轮结束后自动发送」+ 一个「取消排队」按钮;
1148
+ · 排队条(兜底):只有 bubble 没画成(老内核没有 qaRenderMine 返回值、
1149
+ 或画布当时不在)的条目才在这里列一行,保证它们仍然可见、仍然能撤。 */
1150
+ function renderQueue() {
1151
+ var bar = ctx.queueBar;
1152
+ if (!bar) return;
1153
+ bar.innerHTML = '';
1154
+ var orphan = state.queue.filter(function (it) { return !it.bubble || !it.bubble.parentNode; });
1155
+ bar.hidden = !orphan.length;
1156
+ if (!orphan.length) return;
1157
+ bar.appendChild(el('div', 'queuebar__cap', '排队中 · 本轮结束后自动发送'));
1158
+ orphan.forEach(function (it) {
1159
+ var idx = state.queue.indexOf(it);
1160
+ var row = el('div', 'queuechip');
1161
+ row.appendChild(box('queuechip__ico', 'i-clock', 13));
1162
+ var t = el('span', 'queuechip__t', it.text);
1163
+ t.title = it.text;
1164
+ row.appendChild(t);
1165
+ var x = el('button', 'queuechip__x', '✕');
1166
+ x.type = 'button';
1167
+ x.setAttribute('aria-label', '取消这条排队');
1168
+ x.onclick = function () { dropQueued(idx); };
1169
+ row.appendChild(x);
1170
+ bar.appendChild(row);
1171
+ });
1172
+ }
1173
+
1174
+ /* 从队列里撤掉一条:气泡(画过的)与排队条(兜底项)都要清掉,
1175
+ 否则画布上留着一句永远不会被回答的话。 */
1176
+ function dropQueued(idx) {
1177
+ if (idx < 0 || idx >= state.queue.length) return false;
1178
+ var gone = state.queue.splice(idx, 1)[0];
1179
+ if (gone && gone.bubble && gone.bubble.parentNode) gone.bubble.parentNode.removeChild(gone.bubble);
1180
+ /* 磁盘上也要删(宿主的持久化队列),否则下次启动它又冒出来。 */
1181
+ notifyQueue('drop', gone);
1182
+ renderQueue();
1183
+ return true;
1184
+ }
1185
+ /* 在排队气泡上挂一个「取消排队」:撤这一条,并把气泡一起摘掉。
1186
+ 状态说明(「排队中 · 上一轮结束后自动发送」)由 CSS 的 ::after 画,
1187
+ 这里只负责那颗按钮 —— 文案只有一处,改的时候不会两边不一致。 */
1188
+ function mountQueueCancel(bubble, item) {
1189
+ var acts = bubble.querySelector('.qa-acts');
1190
+ if (!acts) return null;
1191
+ var wasHidden = acts.hidden;
1192
+ var b = el('button', 'qa-act qa-act--cancel', '取消排队');
1193
+ b.type = 'button';
1194
+ b.onclick = function (e) {
1195
+ if (e) { e.preventDefault(); e.stopPropagation(); }
1196
+ var i = state.queue.indexOf(item);
1197
+ if (i >= 0) dropQueued(i);
1198
+ noticeItem('已取消这条排队');
1199
+ };
1200
+ acts.hidden = false;
1201
+ acts.appendChild(b);
1202
+ /* 轮到这一条(flushQueue 把它从队列里取走)时必须把这颗按钮摘掉:
1203
+ 它已经不在队列里了,还挂着「取消排队」会让人以为按了能撤,
1204
+ 按下去只会弹一句「已取消这条排队」,其实什么都没撤。 */
1205
+ return function () {
1206
+ if (b.parentNode) b.parentNode.removeChild(b);
1207
+ if (wasHidden) acts.hidden = true;
1208
+ };
1209
+ }
1210
+
1211
+ /* 换会话 / 新建会话:排队条目属于**上一条会话**,不能落到新会话的画布里。
1212
+ 返回取消的条数,由调用方决定怎么如实告知用户(静默丢用户刚写的东西最难查)。 */
1213
+ function clearQueue() {
1214
+ var n = state.queue.length;
1215
+ if (!n) return 0;
1216
+ /* 排队时气泡已经画在画布上了(见 send 的排队分支):换会话就得一并撤掉,
1217
+ 否则那几句话跟着进了新会话的画布,看着像「我在这个会话里问过」。 */
1218
+ state.queue.forEach(function (it) {
1219
+ if (it.bubble && it.bubble.parentNode) it.bubble.parentNode.removeChild(it.bubble);
1220
+ notifyQueue('drop', it);
1221
+ });
1222
+ state.queue = [];
1223
+ renderQueue();
1224
+ return n;
784
1225
  }
785
1226
 
786
- async function runTurn(prompt, attPaths) {
1227
+ function scheduleFlush() {
1228
+ setTimeout(function () {
1229
+ if (!state.busy && !state.replay) flushQueue();
1230
+ }, 0);
1231
+ }
1232
+
1233
+ async function flushQueue() {
1234
+ if (flushing || state.busy || state.replay || !state.queue.length) return;
1235
+ var it = state.queue.shift();
1236
+ /* 轮到它了:摘掉「取消排队」与气泡上的排队标记(这一轮已经开跑,
1237
+ 再不摘就会留下一个按了没用的按钮、和一句与实际状态不符的「排队中」)。 */
1238
+ if (it.unmountCancel) { try { it.unmountCancel(); } catch (e) {} it.unmountCancel = null; }
1239
+ if (it.bubble && it.bubble.classList) it.bubble.classList.remove('is-queued');
1240
+ renderQueue();
1241
+ /* 轮到它了:宿主那条「等待中」该换成真正跑起来的会话(见 notifyQueue)。 */
1242
+ notifyQueue('take', it);
1243
+ flushing = true;
1244
+ try { await fireTurn(it.text, it.atts, it.attPaths, it.bubble); }
1245
+ finally { flushing = false; }
1246
+ /* 连排了多条:这一条也收尾之后再发下一条(让用户看得清是哪条在跑)。 */
1247
+ if (state.queue.length) scheduleFlush();
1248
+ }
1249
+
1250
+ async function runTurn(prompt, attPaths, bubble) {
1251
+ /* 本页原本在尾随别的进程跑的那一轮(follow):现在自己要发新一轮 →
1252
+ 先把旧尾随掐掉。**不能等收尾再掐** —— 旧会话的增量会继续 render,
1253
+ 而新一轮的事件也在同一张画布上,正文就重复了。也不走 stopFollow:
1254
+ 它会把 busy 放开,而这里马上就要置 busy。 */
1255
+ cancelFollowQuiet();
787
1256
  state.busy = true; setSendMode('busy');
1257
+ var askHandle = null; // 本轮 transport.ask 的返回值(收尾时取消用)
1258
+ /* 串行门(beforeAsk)真正把这一轮挡住时,画布上必须看得见「排队中」——
1259
+ 2026-10-21 用户:「其实在串行排队 但是没显示出来任何标识」:气泡已经画出来了,
1260
+ 可除了它什么都没有,看起来就像发出去没人理。
1261
+ ⚠️ 2026-10-22 用户:「现在串行没有排队的也会先显示排队状态再切回」:
1262
+ 这个标记**不再由组件按时间猜**(原先 300ms 后无条件盖上,空间本来就空闲时
1263
+ 只要那道门的一个往返超过 300ms,就先闪一下假的「排队中」再切回去)。
1264
+ 现在只认宿主的裁决 —— `info.queued(true)` 是宿主在**确认要等**时才调的
1265
+ (前面真有队 / 探到该空间在跑),组件自己永不主动盖。 */
1266
+ var markQueued = function (on) {
1267
+ if (!bubble || !bubble.classList) return;
1268
+ if (on) bubble.classList.add('is-queued');
1269
+ else bubble.classList.remove('is-queued');
1270
+ };
788
1271
  /* 发送前的等待门:宿主可在这里「按任务模式」把这一轮挂起(串行模式同空间已有任务时)。
789
- info.isNew = 这是不是一段**新会话的首轮**(历史会话的追问 isNew=false、不受限)。 */
1272
+ info 带上这一轮**完整的下发参数**(session/engine/model/permission/attachments):
1273
+ 串行排队要在宿主侧落库、App 重启后替用户重新发出去,光有 prompt 不够 ——
1274
+ 少了 session 那句追问会变成一段新会话,少了 model 会用错档,少了附件那条任务
1275
+ 跑的就成了别的东西。
1276
+ **返回 false = 用户在排队期间取消了这一轮**:此时不能继续往下发(那会绕过串行门),
1277
+ 把本轮状态原样还回去即可(用户气泡已经在画布上,补一条「已取消排队」说清楚)。 */
790
1278
  if (typeof opts.beforeAsk === 'function') {
791
1279
  setWorking();
792
- try { await opts.beforeAsk({ isNew: !state.session, workspace: state.workspace, engine: state.engine, prompt: prompt }); } catch (e) {}
1280
+ var gate;
1281
+ try {
1282
+ gate = await opts.beforeAsk({
1283
+ isNew: !state.session, session: state.session || '',
1284
+ workspace: state.workspace, engine: state.engine,
1285
+ model: state.model, permission: state.permission,
1286
+ /* team:团队开关也是这一轮的**下发参数**。宿主排队落库后重发时必须还原它,
1287
+ 否则「开了团队模式的任务」在串行队列里会被静默降级成单任务。 */
1288
+ team: state.team ? teamPayload() : null,
1289
+ prompt: prompt, attachments: (attPaths || []).slice(),
1290
+ /* queued(on):宿主**确认这一轮要等**时调它,把「排队中」标到气泡上;
1291
+ 放行时调 queued(false)(或不调,runTurn 收口时会摘)。
1292
+ 组件不按时间猜 —— 空闲时的短等不该闪一下排队态(见上面那段注释)。 */
1293
+ queued: markQueued
1294
+ });
1295
+ } catch (e) { gate = undefined; }
1296
+ /* 门已经开了(或这一轮被取消):先把「排队中」摘掉,别让放行后的气泡还挂着那句话。
1297
+ 取消那条由 markLastMineCancelled 接手续标。 */
1298
+ markQueued(false);
1299
+ if (gate === false) {
1300
+ clearWorking();
1301
+ state.busy = false; state.askId = null; setSendMode('idle');
1302
+ /* 气泡已经画在画布上了(fireTurn 先画再等门):这一轮不会有人回答它,
1303
+ 标成「已取消」比留一句悬空的话强 —— 用户至少知道它被撤了、不是还在等。 */
1304
+ markLastMineCancelled();
1305
+ noticeItem('已取消排队(串行模式)');
1306
+ return;
1307
+ }
793
1308
  }
794
1309
  /* ⚠️ 收尾一律由**内核那一路**做(h.finish / qaRunEnd / qaRenderDone,见 kernelDone)——
795
1310
  这里不再补「收思考区 / 收工具卡 / 把过程并进任务耗时组」那一套(那是旧渲染器的活儿,
@@ -820,6 +1335,10 @@
820
1335
  /* 空值就不传:让 core 用它自己的默认档,不替用户预设(安全语义)。 */
821
1336
  if (state.permission && engineSupportsPermission(state.engine)) payload.permission = state.permission;
822
1337
  if (state.session) payload.session = state.session;
1338
+ /* 团队模式:只在开着时带这个字段(关 = 字段缺省,老 core / 老插件都不受影响)。
1339
+ 路由配置每次下发都现读宿主的配置 —— 用户在「我的」里改了图像引擎,
1340
+ 下一条消息就该用上,不必重新挂载组件。 */
1341
+ if (state.team) payload.team = teamPayload();
823
1342
  await new Promise(function (resolve) {
824
1343
  var resolved = false;
825
1344
  var deliver = function () {
@@ -908,11 +1427,13 @@
908
1427
  hardFinish(code);
909
1428
  };
910
1429
  try {
911
- transport.ask(payload, {
1430
+ askHandle = transport.ask(payload, {
912
1431
  onOpen: function (runID) { state.askId = runID || state.askId; },
913
1432
  onEvent: function (ev) { try { render(ev); } catch (e) {} },
914
1433
  onEnd: finish
915
1434
  });
1435
+ /* 存下取消钩子:kernelDone 在 turn_end 收尾时用它掐断这条流(见那里的注释) */
1436
+ state.askHandle = askHandle;
916
1437
  } catch (e) {
917
1438
  noticeItem('发送失败:' + (e.message || e));
918
1439
  finish(null);
@@ -926,27 +1447,44 @@
926
1447
  内核那条收尾行已经写了,补一条会变成画布上多一行重复的「完成」。 */
927
1448
  clearWorking();
928
1449
  state.busy = false; state.askId = null; setSendMode('idle');
1450
+ /* 只收自己那条流:收尾后用户可能已经发了新一轮,别把新一轮的钩子抹掉 */
1451
+ if (state.askHandle === askHandle) state.askHandle = null;
929
1452
  if (opts.onBusy) { try { opts.onBusy(false); } catch (e) {} }
1453
+ /* 兜底再排一次 flush:有些收尾(没有任何终态事件、qa 都没建起来)
1454
+ 到不了 kernelDone,只有这里会放开 busy。重复调度由 flushing 挡住。 */
1455
+ scheduleFlush();
930
1456
  }
931
1457
  }
932
1458
 
933
1459
  function setSendMode(mode) {
934
1460
  var b = ctx.sendBtn;
1461
+ var busy = mode === 'busy';
1462
+ /* 运行中**有内容**时它不是「停止」而是「排队发送」(2026-10-21 用户:
1463
+ 「会话中运行中允许先提交追问 类似 magic-test pc 端相关」)。
1464
+ 判据与 PC 的 qaSendBtnSync 一致:busy && 输入框有字 → 可发送;busy && 空 → 停止。
1465
+ 输入框里的字一敲一删都会回调这里(见下面 input 监听)。 */
1466
+ var typed = '';
1467
+ try { typed = String(ctx.input.value || '').trim(); } catch (e) { typed = ''; }
1468
+ var stop = busy && !typed && !state.plugin;
935
1469
  /* ⚠️ busy 时**不能**把它 disabled:那样点它不会派发 click,
936
1470
  它就从「中断」退化成一枚按不动的图标。改指中断后,
937
1471
  重复发送由 send() 自己的 state.busy 守卫拦住,不需要靠 disabled 防。 */
938
1472
  b.disabled = false;
939
- b.classList.toggle('sendbtn--stop', mode === 'busy');
1473
+ b.classList.toggle('sendbtn--stop', stop);
940
1474
  b.innerHTML = '';
941
- b.appendChild(svgIcon(mode === 'busy' ? 'i-clock' : 'i-up', 17));
942
- b.setAttribute('aria-label', mode === 'busy' ? '中断' : '发送');
943
- b.title = mode === 'busy' ? '中断这一轮' : '发送';
944
- /* 「生成中」提示行(2026-10-20 用户:「两个胶囊合并成1个 在下面显示生成中效果即可」):
945
- 这枚胶囊**下面**那一行小字,就是合并后「这一轮在跑」的表达 ——
946
- 画布底部内核那枚同文案的胶囊在本壳不画(见 conversation.css)。
1475
+ /* 停止用方块、且比发送箭头略小(方块本身比箭头「重」,同尺寸会显得头重脚轻) */
1476
+ b.appendChild(svgIcon(stop ? 'i-stop' : 'i-up', stop ? 14 : 17));
1477
+ b.setAttribute('aria-label', !busy ? '发送' : (stop ? '中断' : '排队发送'));
1478
+ b.title = !busy ? '发送'
1479
+ : (stop ? '中断这一轮' : '排队发送 · 上一条处理中,结束后自动提交');
1480
+ /* 运行态并进引擎 · 模型那枚胶囊(2026-10-20 用户:「两个胶囊合并成1个」
1481
+ →「是图中胶囊下方的生成中 应该跟胶囊合并」→「不需要文字了 用其他动画效果代替」)——
1482
+ 运行态时这枚胶囊**自己**变成运行态:前面的小图标换成绿液瓶(会涨落)。
947
1483
  ⚠️ 跟着 busy 走(setSendMode 是 busy 的唯一出口:发一轮 / 收一轮 / 续传轮
948
- 都经过它),所以不需要另开一个状态位。 */
949
- if (ctx.genRow) ctx.genRow.classList.toggle('on', mode === 'busy');
1484
+ 都经过它),所以不需要另开一个状态位。
1485
+ ⚠️ 引擎·模型的真值仍在胶囊里:降级换模型时 'start' 事件会改写它
1486
+ (见 kernelApply 的 'start' 分支 → syncPickers),所以不丢信息。 */
1487
+ if (ctx.casc) ctx.casc.classList.toggle('is-running', mode === 'busy');
950
1488
  }
951
1489
 
952
1490
  /* ══════════ 底部抽屉组件 Sheet(引擎 / 模型 / 授权共用)══════════
@@ -1086,7 +1624,9 @@
1086
1624
  models.forEach(function (mm) {
1087
1625
  var b = el('button', 'pick__citem' + (state.engine === cur.engine && state.model === mm ? ' is-on' : ''));
1088
1626
  b.type = 'button';
1089
- b.appendChild(el('span', 'pick__cl', mm));
1627
+ /* 清单项显示剥离前缀后的名字(`mm` 本身是 CLI 认的完整 id,选它时原样写回)。
1628
+ 倍率(`model_credits`)靠右挂在同一行:模型名再长也只挤自己,倍率列不会错位。 */
1629
+ b.appendChild(modelRow(mm, qaModelCredit(state.engines, cur.engine, mm)));
1090
1630
  b.onclick = function () { state.engine = cur.engine; state.model = mm; syncPickers(); notifyPick(); Sheet.close(); };
1091
1631
  right.appendChild(b);
1092
1632
  });
@@ -1094,6 +1634,104 @@
1094
1634
  paint();
1095
1635
  }
1096
1636
 
1637
+ /* ══════════ 团队模式 ══════════
1638
+ 开关本身很薄:它只决定 payload 里带不带 `team`。真正的编排全在 core
1639
+ (internal/team:主引擎拆任务 → 子引擎分头跑 → 主引擎汇总),界面上的表现
1640
+ 复用**委派卡**(子任务的事件带 parent,内核早就画成嵌套卡了)—— 所以这里
1641
+ 不需要任何画法,也不需要新的事件类型。
1642
+
1643
+ 为什么只传「配置」不传「模式细节」:parallel / 路由都是用户的**偏好**(「我的」页里配的),
1644
+ 由宿主持有并通过 opts.teamConfig 交给组件。组件每轮现读一次 —— 用户在设置里
1645
+ 换了图像引擎,下一条消息就生效,不必重新挂载。 */
1646
+ function teamPayload() {
1647
+ var cfg = null;
1648
+ if (typeof opts.teamConfig === 'function') {
1649
+ try { cfg = opts.teamConfig(); } catch (e) { cfg = null; }
1650
+ }
1651
+ var out = {};
1652
+ var routes = (cfg && cfg.routes) || null;
1653
+ if (routes && typeof routes === 'object') {
1654
+ var clean = {};
1655
+ Object.keys(routes).forEach(function (k) {
1656
+ var r = routes[k] || {};
1657
+ var engine = String(r.engine || '').trim();
1658
+ if (!engine) return; // 没选引擎 = 这一类跟随主引擎(不传空对象,core 会当成显式指定)
1659
+ var item = { engine: engine };
1660
+ var model = String(r.model || '').trim();
1661
+ if (model) item.model = model;
1662
+ clean[k] = item;
1663
+ });
1664
+ if (Object.keys(clean).length) out.routes = clean;
1665
+ }
1666
+ var n = parseInt((cfg && cfg.parallel), 10);
1667
+ if (n > 0) out.parallel = n;
1668
+ // 拆解规则:粒度只认三档(写错就按 core 默认 standard,不让脏值把请求打成 400);
1669
+ // 两个布尔是「显式表达偏好」,只有与 core 默认不同才传,免得每轮都塞无意义的字段。
1670
+ var split = String((cfg && cfg.split) || '').trim().toLowerCase();
1671
+ if (split === 'coarse' || split === 'standard' || split === 'fine') out.split = split;
1672
+ if (cfg && cfg.splitCode === true) out.splitCode = true;
1673
+ if (cfg && cfg.adaptive === false) out.adaptive = false;
1674
+ // 角色规则:空文本不传(留空 = 用 core 内置的「这类任务适合什么」)。
1675
+ var rules = (cfg && cfg.rules) || null;
1676
+ if (rules && typeof rules === 'object') {
1677
+ var cleanRules = {};
1678
+ Object.keys(rules).forEach(function (k) {
1679
+ var t = String(rules[k] || '').replace(/\s+/g, ' ').trim();
1680
+ if (t) cleanRules[k] = t;
1681
+ });
1682
+ if (Object.keys(cleanRules).length) out.rules = cleanRules;
1683
+ }
1684
+ return out;
1685
+ }
1686
+
1687
+ /* 团队开关的唯一写入口。两件事必须一起发生,否则会出现「界面开了、下一轮没开」
1688
+ (或者反过来):① 改自己的 state 并重画;② 叫醒宿主 —— 开关的**权威状态**在
1689
+ 宿主的按会话记录里(每条会话一份、落 localStorage),组件只是这一轮的执行者。 */
1690
+ function setTeamState(on) {
1691
+ state.team = !!on;
1692
+ syncPickers();
1693
+ if (opts.onTeamChange) { try { opts.onTeamChange(state.team); } catch (e) {} }
1694
+ }
1695
+
1696
+ /* 团队开关的选择器。两个入口(会话页右上角的运行设置、我的页的团队模式卡片)
1697
+ 都调它 —— 「选择器只有一份」是这套 UI 的硬约束(与权限选择器同理)。 */
1698
+ function openTeamPicker(anchor) {
1699
+ Sheet.open(anchor, function (M) {
1700
+ M.title('团队模式');
1701
+ if (!featureOn('feature.session.team')) {
1702
+ M.row({ label: '当前 core 不支持团队模式',
1703
+ desc: '需要升级 magic-agent(缺 feature.session.team 能力位)' });
1704
+ return;
1705
+ }
1706
+ M.row({
1707
+ label: '关闭', desc: '一个引擎从头做到尾(默认)',
1708
+ selected: !state.team,
1709
+ onPick: function () { setTeamState(false); }
1710
+ });
1711
+ M.row({
1712
+ label: '开启', desc: '主引擎拆任务 → 子引擎分头执行 → 主引擎汇总;'
1713
+ + '每个子任务在对话里是一张委派卡(哪类活交给谁,按引擎擅长什么在设置里配)',
1714
+ selected: !!state.team,
1715
+ onPick: function () { setTeamState(true); }
1716
+ });
1717
+ });
1718
+ }
1719
+
1720
+ /* core 能力位(feature.*)是否具备。清单来自 /desk/health 或 /desk/contract,
1721
+ 由宿主经 opts.features 交进来 —— 可以是**数组**,也可以是**函数**(推荐:
1722
+ 能力清单是异步拉到的,函数形式让「查到之后才亮出开关」不需要重新挂载组件)。
1723
+ **没给 / 空 = 不知道 = 当作不支持**(诚实降级:宁可不显示开关,
1724
+ 也不要让用户打开一个 core 会以 exit 2 拒绝的选项)。 */
1725
+ function featureOn(name) {
1726
+ var list = opts.features;
1727
+ if (typeof list === 'function') {
1728
+ try { list = list(); } catch (e) { list = null; }
1729
+ }
1730
+ if (!list) return false;
1731
+ if (typeof list.indexOf === 'function') return list.indexOf(name) >= 0;
1732
+ return !!list[name];
1733
+ }
1734
+
1097
1735
  function openPermissionPicker(anchor) {
1098
1736
  Sheet.open(anchor, function (M) {
1099
1737
  M.title('授权档位');
@@ -1230,8 +1868,12 @@
1230
1868
  }
1231
1869
 
1232
1870
  /* 把画面接回一条**还在跑**的轮次(2026-10-20 用户:「切回来还是没好」)。
1233
- 打开会话时若发现同会话的轮次仍在跑:历史只回放到那条 user,这里从 ask 缓冲
1234
- `after=0` 补齐并持续增量补拉,直到终态;同时置 busy(拦住误发新消息)。
1871
+ 从本进程的 ask 缓冲 `after=0` 补齐并持续增量补拉,直到终态;同时置 busy。
1872
+
1873
+ ⚠️ 2026-10-21 起这是**兜底**通道:网关有 `/follow` 时优先尾随磁盘日志
1874
+ (见 loadHistory 的接续一段)。只在这两种情况下走到这里:
1875
+ · 旧网关没有 followSession;
1876
+ · follow 建不起来(连不上 / 立刻报错)。
1235
1877
 
1236
1878
  alreadyRenderedUser:那条 user 已经由日志重放画过了(loadHistory 的 partial 分支)。
1237
1879
  网关从 2026-10-20 起会在缓冲头合成一条 `type:"user"`(core 的事件流里没有这条),
@@ -1289,7 +1931,129 @@
1289
1931
  return true;
1290
1932
  }
1291
1933
 
1292
- /* 相邻 thinking 分片并成一条(只并相邻;用数组攒再 join,别用 text += 逐片拼)。 */
1934
+ /* ══════════ 尾随一条还在跑的会话(磁盘日志,跨进程)══════════
1935
+ 2026-10-21 报障:「移动端发出的回话在 PC 端打开看不到进度,PC 端发出的移动端看不到」。
1936
+ 上一节的 attachRunningAsk 走 /desk/asks —— 那是**本插件进程内存里**的轮次:
1937
+ 掌天瓶桌面壳直接 exec core,它起的一轮在本插件里从来不存在(反之亦然)。
1938
+ 这里走网关的 /desk/session/{id}/follow:服务端读磁盘上那条会话日志
1939
+ (跨进程共享,谁跑的都一样),按 ~500ms 增量推送,一次连接跟到终态。
1940
+
1941
+ ⚠️ 2026-10-21 起它是**首选**通道(见 loadHistory 的接续一段):只要清单说这条会话
1942
+ 还在跑就用它,本进程有 run 也不例外 —— ask 缓冲的 seq 口径与文件不同、且有上限裁剪,
1943
+ 长轮次里必然「进来看一眼就静止」。attachRunningAsk 降级为「网关没有 /follow」时的兜底。
1944
+
1945
+ 收摊三种:END 帧 / 换会话或组件销毁(stopFollow)/ 游标超前(重拉历史)。 */
1946
+ /* 文件尾那条事件的 seq —— 尾随游标(follow 的 `after`)。
1947
+ ⚠️ 不能用「全文件最大」:续跑会让 seq 回落,取最大会得到比文件尾还大的游标,
1948
+ 网关据此判「游标超前」→ 回 snapshot 帧 → 客户端清屏重拉 → 一直循环。
1949
+ 文件顺序即时间顺序,最后一条的 seq 就是「已经画到哪」。 */
1950
+ function tailEventSeq(list) {
1951
+ for (var i = list.length - 1; i >= 0; i--) {
1952
+ var n = Number(list[i] && list[i].seq);
1953
+ if (isFinite(n) && n > 0) return n;
1954
+ }
1955
+ return 0;
1956
+ }
1957
+ function sessionLive() {
1958
+ var m = state.meta;
1959
+ if (!m) return false;
1960
+ /* `status` 是会话级统一状态(core 的 --sessions,与标题同源保存);`state` 只是
1961
+ 「挑中的那一轮」的状态,两端挑中不同轮会得出不同结论。老版本 CLI 没有 status 时回落。 */
1962
+ var st = String(m.status || m.state || '');
1963
+ return !!(m.alive || st === 'running' || st === 'waiting_approval');
1964
+ }
1965
+ /* 断流。busy 是 follow 置的(没有本地 askId)就一并放开,别让输入框一直卡在「发送中」。
1966
+ askId 由 follow 借来(见 startFollow 的 askID 参数)也一并还回去 —— 否则换会话后
1967
+ 「打断」会打到一个早已不存在的轮次上。 */
1968
+ function stopFollow() {
1969
+ var f = state.follow;
1970
+ if (!f) return;
1971
+ state.follow = null;
1972
+ try { if (f.cancel) f.cancel(); } catch (e) {}
1973
+ if (f.askId && state.askId === f.askId) state.askId = null;
1974
+ if (!state.askId) {
1975
+ clearWorking();
1976
+ state.busy = false;
1977
+ setSendMode('idle');
1978
+ if (opts.onBusy) { try { opts.onBusy(false); } catch (e) {} }
1979
+ }
1980
+ }
1981
+ /* 只掐连接,**不碰** busy / 输入框:用于「本页自己发起新一轮」——
1982
+ 那一轮由 runTurn 管状态,而旧的那条尾随必须立刻断开(否则它的增量会和新一轮
1983
+ 的实时事件写进同一张卡,正文重复一遍)。 */
1984
+ function cancelFollowQuiet() {
1985
+ var f = state.follow;
1986
+ if (!f) return;
1987
+ state.follow = null;
1988
+ try { if (f.cancel) f.cancel(); } catch (e) {}
1989
+ if (f.askId && state.askId === f.askId) state.askId = null;
1990
+ }
1991
+ function followEnd(info) {
1992
+ var f = state.follow;
1993
+ if (!f) return;
1994
+ state.follow = null;
1995
+ var reason = String((info && info.reason) || '');
1996
+ var st = String((info && info.state) || '');
1997
+ clearWorking();
1998
+ state.busy = false;
1999
+ state.askId = null;
2000
+ state.resumeT0 = 0;
2001
+ setSendMode('idle');
2002
+ if (opts.onBusy) { try { opts.onBusy(false); } catch (e) {} }
2003
+ /* 尾随的这条会话跑完了:排在输入框上方的追问现在可以发(同上,排队不打断)。 */
2004
+ scheduleFlush();
2005
+ /* 记下「这条会话刚由 follow 跟到终态」:core 退出后 /desk/asks 里还会留这个 run 一会儿
2006
+ (askRetention),停留期巡检若据此重拉会把刚画完的画面整屏重画一遍(长会话=一次
2007
+ 全量读)。见 watchTick 的抑制判据。 */
2008
+ if (reason === 'terminal') state.followDone = { sid: state.session, at: Date.now() };
2009
+ if (state.qaClosed || !state.qa || !state.qa.h) return;
2010
+ if (reason === 'terminal') {
2011
+ var ok = !(st === 'failed' || st === 'cancelled' || st === 'interrupted' || st === 'gone');
2012
+ closeAttached(ok, ok ? '已完成' : '这一轮已结束');
2013
+ } else if (reason === 'max_duration') {
2014
+ noticeItem('进度同步到时(30 分钟),重开会话可继续同步');
2015
+ closeAttached(false, '进度同步已暂停');
2016
+ } else {
2017
+ closeAttached(false, '进度同步已断开 · 重开会话可继续');
2018
+ }
2019
+ }
2020
+ /* after = 已经画到哪(日志 seq)。askID = 本进程里这一轮的 id(跨进程跟随时没有)。
2021
+ 返回是否真的跟上了。 */
2022
+ function startFollow(sid, after, askID) {
2023
+ stopFollow();
2024
+ if (!sid || typeof transport.followSession !== 'function') return false;
2025
+ var tok = { sid: sid, cancel: null, askId: askID || null };
2026
+ var h = transport.followSession(String(sid), after || 0, {
2027
+ onEvent: function (ev) {
2028
+ if (state.follow !== tok || sid !== state.session) return;
2029
+ try { render(ev); } catch (e) { /* 单条坏事件吞掉 */ }
2030
+ },
2031
+ onSnapshot: function () {
2032
+ /* 游标超前(日志被重置 / 换了会话)→ 整份重来,别在半截上续。
2033
+ ⚠️ 必须连 busy 一起放开(并把借来的 askId 还掉):loadHistory 只在「本页空闲」
2034
+ 时才肯重新挂 follow,否则重拉出来的是一份不跟流的静态历史。 */
2035
+ if (state.follow !== tok || sid !== state.session) return;
2036
+ if (tok.askId && state.askId === tok.askId) state.askId = null;
2037
+ stopFollow();
2038
+ clearThread();
2039
+ threadEl.appendChild(empty);
2040
+ state.qaClosed = false;
2041
+ loadHistory(sid).catch(function () { /* 下一拍由用户重开 */ });
2042
+ },
2043
+ onEnd: function (info) { if (state.follow === tok) followEnd(info); },
2044
+ onError: function () { if (state.follow === tok) followEnd({ reason: 'error' }); }
2045
+ });
2046
+ if (!h || typeof h.cancel !== 'function') return false;
2047
+ tok.cancel = h.cancel;
2048
+ state.follow = tok;
2049
+ if (askID) state.askId = askID;
2050
+ state.busy = true;
2051
+ setSendMode('busy');
2052
+ setWorking();
2053
+ if (opts.onBusy) { try { opts.onBusy(true); } catch (e) {} }
2054
+ return true;
2055
+ }
2056
+
1293
2057
  function mergeThinkDeltas(list) {
1294
2058
  var out = [];
1295
2059
  for (var i = 0; i < list.length; i++) {
@@ -1386,6 +2150,10 @@
1386
2150
  140ms 内就回来的(本地小会话)根本不亮,避免「刚出现就消失」的抖动。
1387
2151
  ⚠️ 动画只动 transform / opacity(见 conversation.css):回放大日志会占死主线程,
1388
2152
  动 layout 的属性会让这一屏跟着冻住,看上去就像「卡在读取」。 */
2153
+ /* 已经为哪条会话拉过历史(首载发起时落定,换会话 / 读失败时清空)。
2154
+ `setSession` 的同会话短路要看它:只有「历史确实在这块画布里」才跳过重放,
2155
+ 否则 `create({session})` 那条路(state.session 事先就有值)会被误判成重入。 */
2156
+ var histSid = null;
1389
2157
  var histLoading = null; // loading 节点(懒建:没读过历史就不建)
1390
2158
  var histLoadingWant = false; // 这一轮首载还没结束
1391
2159
  var histLoadingTimer = 0; // 「140ms 还没回来才显示」的定时器
@@ -1430,7 +2198,7 @@
1430
2198
 
1431
2199
  function loadHistory(sid, after) {
1432
2200
  if (!sid) return Promise.resolve(0);
1433
- if (after == null) beginHistLoading();
2201
+ if (after == null) { histSid = sid; beginHistLoading(); }
1434
2202
  /* 打开会话时**并行**问两件事:日志 + 本进程有没有挂在同一会话上的运行中轮次。
1435
2203
  后者决定历史回放到哪(见 lastTurnInfo);拉不到就当没有 ——
1436
2204
  旧网关没有 /desk/asks,不能因为它让历史打不开。 */
@@ -1464,15 +2232,37 @@
1464
2232
  /* ⚠️ 回放标记(2026-10-12):回放**不走 seq 去重**(见 render 开头那段)——
1465
2233
  日志是跨轮追加的,老日志里每轮的 seq 都从 1 重来,去重会把第二轮起的事件当成
1466
2234
  重复帧全丢掉。打了这个标,render 那边就跳过 seq 判重。 */
2235
+ var canFollow = typeof transport.followSession === 'function';
1467
2236
  var run = runningAskOf(rs[1], sid);
1468
2237
  var info = lastTurnInfo(events);
1469
- /* 有运行中的同会话轮次、且日志里那一轮还没收尾 → 只重放到那条 user(含),
1470
- 提问之后的助手侧内容一律交给 ask 缓冲补齐(日志可能落后 / seq 口径不同)。 */
1471
- var partial = !!(run && info.index >= 0 && !info.terminal);
2238
+ /* 「本页要接着看一条还在跑的会话」——两个来源任一成立:
2239
+ · 清单说它还在跑(sessionLive)—— 跨进程跑的那一轮在本插件里根本不存在
2240
+ (桌面壳直接 exec core、或另一台设备起的),只能看清单;
2241
+ · 本进程还有它的运行中轮次(run)—— 清单行可能是进页面时拿的旧快照。
2242
+ 只首载跟一次:分页拉「更早」不该把流接回去。 */
2243
+ var live = !!(after == null && sid === state.session && !state.busy
2244
+ && (sessionLive() || !!run));
2245
+ /* ⚠️ 通道在这里一次定死(回放怎么画取决于走哪条):
2246
+ · useFollow —— 磁盘日志(首选,见下面的说明);
2247
+ · useAsk —— 本进程的 ask 缓冲(网关没有 /follow 时的兜底)。
2248
+ `info.terminal` = 日志里这一轮**已经有终态**:那就是真的跑完了(清单行可能是滞后的
2249
+ 旧快照)。这时两条都不该挂 —— 否则会先亮起「生成中」、再被 follow 的 END 摘掉,
2250
+ 白白闪一下,还会把已经封口的画面再解封。 */
2251
+ var useFollow = live && canFollow && !info.terminal;
2252
+ var useAsk = !useFollow && live && !!run && !info.terminal;
2253
+ /* partial = 「这一轮还没收尾,但首载之后还要接着看它」→ 回放**不封口**
2254
+ (封口后后续事件会另起一张卡,同一轮变成两张、正文各半截)。
2255
+ 重放到哪分两种:
2256
+ · 接 ask 缓冲:截到那条 user,其余交给缓冲 —— 缓冲的 seq 是 core 每轮的原始
2257
+ 序号,与文件 seq 不同口径,重放多了会对不上(见 attachRunningAsk 的说明);
2258
+ · 尾随磁盘日志:**全量重放**,游标接在文件末尾 —— 同一份数据同一口径,
2259
+ 磁盘上有多少画多少,剩下的由 follow 增量推。 */
2260
+ var partial = !!((useFollow || useAsk) && info.index >= 0);
2261
+ var replayTo = useAsk ? Math.max(0, info.index + 1) : events.length;
1472
2262
  state.replay = true;
1473
2263
  state.replayT0 = 0; state.replayLastAt = 0;
1474
2264
  try {
1475
- var replayList = events.slice(0, partial ? info.index + 1 : events.length);
2265
+ var replayList = events.slice(0, partial ? replayTo : events.length);
1476
2266
  /* 相邻思考增量先并成一段再喂(2026-10-20 用户:「移动端加载也很慢」):
1477
2267
  日志是按流式增量落盘的,一条长会话实测 95838 条事件里 94015 条是
1478
2268
  ~3 字符的 thinking 分片 —— 逐条 render 要 16s、主线程整段占死。
@@ -1486,7 +2276,7 @@
1486
2276
  });
1487
2277
  /* 日志尾部没有终态事件(那一轮被中断)→ 把最后一轮也封口,
1488
2278
  否则底部会永远留着一枚「生成中」胶囊。
1489
- 续传中的那一轮**不封**:它的收尾由 ask 缓冲的终态事件落定。 */
2279
+ 续传中的那一轮(partial)**不封**:它的收尾由后面接上的实时通道落定。 */
1490
2280
  if (!partial) finishReplayRound();
1491
2281
  } finally { state.replay = false; }
1492
2282
  var t = thread(); t.scrollTop = t.scrollHeight;
@@ -1496,8 +2286,34 @@
1496
2286
  /* 分页游标落定后才显示入口(旧服务端 hist = null → 不显示,行为与改动前一致)。 */
1497
2287
  state.hist = after == null ? hist : state.hist;
1498
2288
  syncMore();
1499
- /* partial 时那条 user 已由上面的日志重放画过 → 让 attach 跳过网关合成的同一条。 */
1500
- if (run) attachRunningAsk(run, info.at, partial);
2289
+ /* 接续(2026-10-21 用户:「在会话列表看到还在跑 点进来就没增量」):
2290
+ **磁盘日志优先**。只要清单说这条会话还在跑,就尾随网关的文件增量(/follow,
2291
+ 跨进程、只追加、不裁剪);本进程的 ask 缓冲只在日志通道不可用时兜底。
2292
+
2293
+ 为什么反过来(原来是有 run 就 attachRunningAsk):ask 缓冲有两个致命口径问题,
2294
+ 长轮次下必然「进来看一眼就静止」——
2295
+ ① 缓冲里事件的 seq 是 **core 每轮的原始序号**(每轮从 1 起),文件的是跨轮
2296
+ 接号的(见 cli/sessionlog.go 的 openFile)。拿缓冲 seq 当游标续拉,
2297
+ 在第二次之后的轮次里永远 `after > lastSeq` → 一条都补不到;
2298
+ ② 缓冲有上限(askBufMaxEvents / askBufMaxBytes),超了从头裁并置 dropped,
2299
+ 于是 after=0 直接 snapshotRequired,中间过程永久缺失。
2300
+ 而文件日志是同一份、同一口径、且 500ms 就 flush 一次(跨进程也读得到)。
2301
+ 只首载跟一次:分页拉「更早」不该把流接回去。 */
2302
+ var attached = false;
2303
+ if (useFollow) {
2304
+ /* 耗时的起点是日志里那条 user 的墙钟(不是「进这条会话的时刻」)——
2305
+ 否则「已完成 · 总耗时 1.2s」会把跑了十分钟的轮次写成两秒(见 kernelDone 的 resumeT0)。 */
2306
+ state.resumeT0 = info.at || 0;
2307
+ /* 本进程正好也有这条会话的轮次时把 id 交出去:「打断」才打得到(跨进程的没有)。 */
2308
+ attached = startFollow(sid, tailEventSeq(events), run && run.id);
2309
+ }
2310
+ /* 兜底:网关没有 /follow(旧版)时走本进程的 ask 缓冲。
2311
+ ⚠️ 只在 useAsk 为真时接:那时回放**截到了那条 user**,缓冲从 0 补不会重影。
2312
+ 反过来(日志已全量重放、再拿缓冲从 0 补)会把整轮画第二遍。 */
2313
+ if (!attached && useAsk) attached = attachRunningAsk(run, info.at, partial);
2314
+ /* 说好要接着看、结果一条通道都没接上(旧网关 / 读盘抖动)→ 把这一轮按「已结束」封口,
2315
+ 否则底部会永远留着一枚「生成中」胶囊,且输入框一直卡在「发送中」。 */
2316
+ if (partial && !attached) finishReplayRound();
1501
2317
  /* 首载结束:骨架屏撤下。一条都没画出来(空会话)→ 把空态那句还回去。
1502
2318
  ⚠️ 期间用户可能已经切走(迟到的这一份不许撤新会话的骨架屏)。 */
1503
2319
  if (after == null && sid === state.session) endHistLoading(true);
@@ -1505,7 +2321,10 @@
1505
2321
  });
1506
2322
  /* 读失败也要撤骨架屏(否则它会一直挂着,看着像永远读不完)。 */
1507
2323
  return done.catch(function (e) {
1508
- if (after == null && sid === state.session) endHistLoading(true);
2324
+ if (after == null && sid === state.session) {
2325
+ endHistLoading(true);
2326
+ histSid = null; // 读失败不算「已在这块画布里」:下次重开还要再试
2327
+ }
1509
2328
  throw e;
1510
2329
  });
1511
2330
  }
@@ -1613,6 +2432,9 @@
1613
2432
  if (watchTimer) { clearInterval(watchTimer); watchTimer = 0; }
1614
2433
  watchSeen = '';
1615
2434
  watchInflight = false;
2435
+ /* 实时的文件尾随同属「停留期的后台同步」:换会话 / 新建 / 销毁时一并掐掉
2436
+ (否则旧会话的事件会继续写进清空后的画布,见 startFollow 的 tok 守卫)。 */
2437
+ stopFollow();
1616
2438
  }
1617
2439
  function watchTick() {
1618
2440
  var sid = state.session;
@@ -1626,6 +2448,10 @@
1626
2448
  var run = runningAskOf(asks, sid);
1627
2449
  /* 同一条 run 只刷一次:接不上(旧网关缺 askEvents)时别每 5s 重画一遍。 */
1628
2450
  if (!run || watchSeen === run.id) return;
2451
+ /* 这条会话刚被 follow 跟到终态(<15s):/desk/asks 里那个 run 还没过保留期,
2452
+ 此时重拉 = 把刚画完的一整轮再画一遍(长会话是一次全量读)。 */
2453
+ if (state.followDone && state.followDone.sid === sid
2454
+ && Date.now() - state.followDone.at < 15000) return;
1629
2455
  watchSeen = run.id;
1630
2456
  clearThread();
1631
2457
  threadEl.appendChild(empty);
@@ -1645,7 +2471,9 @@
1645
2471
  var casc = root.querySelector('.casc');
1646
2472
  if (casc) {
1647
2473
  casc.querySelector('.casc__p').textContent = state.engine || '选择引擎';
1648
- casc.querySelector('.casc__s').textContent = state.model || '默认模型';
2474
+ /* 展示走 qaModelText:剥掉 codebuddy 系自定义模型的 `custom-local:` 前缀
2475
+ (`state.model` 本身仍是完整 id,`-m` 要用它)。 */
2476
+ casc.querySelector('.casc__s').textContent = qaModelText(state.model) || '默认模型';
1649
2477
  }
1650
2478
  var chip = ctx.permBtn;
1651
2479
  if (chip) {
@@ -1689,6 +2517,18 @@
1689
2517
  引擎提问 / 待授权时卡钉在输入框上方。显隐与进出全由内核管,这里只给一块地。 */
1690
2518
  var askDock = el('div', 'qa-askdock'); askDock.id = 'home-askdock'; askDock.hidden = true;
1691
2519
  cbox.appendChild(askDock);
2520
+ /* 排队追问的停靠位(2026-10-21):运行中提交的那几条排在这里,不进画布 ——
2521
+ 它们**还没跑**,画成用户气泡会让人以为已经发出去了。位置贴着输入框上沿
2522
+ (与 PC 的 qaMsgList 同一意图:「新需求排列在会话框上面」)。 */
2523
+ var queueBar = el('div', 'queuebar'); queueBar.hidden = true;
2524
+ ctx.queueBar = queueBar;
2525
+ cbox.appendChild(queueBar);
2526
+ /* 选中的插件胶囊的**停靠位**:在附件条上方、输入框之外。
2527
+ ⚠️ 不塞进 .attachbar 里:那是附件的地盘,混在一起后 clearAttachments
2528
+ (每轮必跑)会把胶囊一起清掉,而插件是「本轮怎么干」不是附件。 */
2529
+ var plugBar = el('div', 'plugbar'); plugBar.hidden = true;
2530
+ ctx.plugBar = plugBar;
2531
+ cbox.appendChild(plugBar);
1692
2532
  var attachBar = el('div', 'attachbar'); attachBar.hidden = true;
1693
2533
  ctx.attachBar = attachBar;
1694
2534
  cbox.appendChild(attachBar);
@@ -1703,12 +2543,25 @@
1703
2543
  }
1704
2544
  var casc = el('button', 'casc');
1705
2545
  casc.type = 'button';
1706
- casc.appendChild(svgIcon('i-cpu', 14));
2546
+ /* 前面这一格放两样东西:平时是「引擎」小图标,跑的时候换成绿液瓶(掌天瓶那套加载图形)。
2547
+ 「生成中」不配文字(2026-10-20 用户:「不需要文字了 用其他动画效果代替」)——
2548
+ 一枚会动的小瓶比三个字更轻,也不占宽度。
2549
+ ⚠️ 换图标而不换整枚胶囊:胶囊本身就是引擎·模型选择器(点它开抽屉),
2550
+ 换掉整枚会让人以为运行态不能点。 */
2551
+ var lead = el('span', 'casc__lead');
2552
+ var cpuIco = svgIcon('i-cpu', 14);
2553
+ cpuIco.classList.add('casc__cpu');
2554
+ lead.appendChild(cpuIco);
2555
+ var bottleBox = el('span', 'casc__bottle');
2556
+ bottleBox.appendChild(bottleSVG({ fill: 0.62, anim: 'hist' }));
2557
+ lead.appendChild(bottleBox);
2558
+ casc.appendChild(lead);
1707
2559
  casc.appendChild(el('span', 'casc__p', '选择引擎'));
1708
2560
  casc.appendChild(el('span', 'casc__sep', '·'));
1709
2561
  casc.appendChild(el('span', 'casc__s', '默认模型'));
1710
2562
  casc.appendChild(svgIcon('i-chev', 13));
1711
2563
  casc.className = 'casc';
2564
+ ctx.casc = casc;
1712
2565
  ctx.permBtn = el('button', 'permchip');
1713
2566
  ctx.permBtn.type = 'button';
1714
2567
  ctx.permBtn.appendChild(svgIcon('i-set', 14));
@@ -1720,23 +2573,11 @@
1720
2573
  var trow = el('div', 'composer__tools');
1721
2574
  trow.appendChild(casc);
1722
2575
  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;
1734
2576
  ctx.sendBtn = el('button', 'sendbtn');
1735
2577
  ctx.sendBtn.type = 'submit';
1736
2578
  crow.appendChild(cleft); crow.appendChild(ctx.sendBtn);
1737
2579
  cbox.appendChild(crow);
1738
2580
  form.appendChild(trow);
1739
- form.appendChild(genRow);
1740
2581
  form.appendChild(cbox);
1741
2582
  wrap.appendChild(form);
1742
2583
  root.appendChild(wrap);
@@ -1749,6 +2590,11 @@
1749
2590
  /* 代码块与工具明细的复制按钮、提问卡选项、思考块折叠……这些点击**全部**走内核那份委托
1750
2591
  (`shared/conv/07-stream-delegation.js` 的 bindQaStreamDelegation,在下面挂桥那段绑一次)。
1751
2592
  本文件不再挂自己的画布委托 —— 节点随快照重建也不会失效。 */
2593
+ /* 内核的「用户离开底部」闸(2026-10-21 用户:「移动端对话框向上滑动看历史时总又自动跳回
2594
+ 聚焦到结尾」):这套监听维护 `qaUserAway`,而 `qaScrollIntoView` 靠它决定要不要把画布
2595
+ 贴到底。它原来只在圆点轨的 qaDotsBind 里绑,本组件从不调那个 —— 闸恒开,于是手机上
2596
+ 每一次上滑都被下一笔流式增量拽回结尾。这里补上(幂等,圆点轨复用时不会再绑一遍)。 */
2597
+ if (typeof qaScrollGateBind === 'function') qaScrollGateBind();
1752
2598
  threadEl.addEventListener('scroll', function () {
1753
2599
  state.autoFollow = nearBottom();
1754
2600
  syncBottomBtn();
@@ -1759,16 +2605,91 @@
1759
2605
  threadEl.addEventListener('wheel', function (e) {
1760
2606
  if (e.deltaY < 0 && threadEl.scrollHeight <= threadEl.clientHeight + 8) loadMore();
1761
2607
  }, { passive: true });
1762
- ctx.input.addEventListener('input', autoGrow);
2608
+ ctx.input.addEventListener('input', function () {
2609
+ autoGrow();
2610
+ /* 运行中输入/删除文字:发送键在「停止」与「排队发送」之间当场切换
2611
+ (PC 的 qaSendBtnSync 挂在 input 上,同一口径)。 */
2612
+ if (state.busy) setSendMode('busy');
2613
+ });
1763
2614
  form.addEventListener('submit', function (e) { e.preventDefault(); send(); });
1764
2615
  ctx.input.addEventListener('keydown', function (e) {
1765
2616
  if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); }
1766
2617
  });
2618
+ /* ── 粘贴 / 拖入图片(2026-10-21 用户:「对话框中现在没支持粘贴图片」)──
2619
+ 与 + 菜单里的「照片 / 文件」走**同一条**上传管线(uploadFiles → maybeNormalize
2620
+ → POST /desk/upload),拿到 core 那台机器的绝对路径后随 payload.attachments 下发。
2621
+ ⚠️ 只认「真的带图片文件」的剪贴板:纯文本 / 富文本粘贴必须继续走浏览器默认行为 ——
2622
+ 无差别 preventDefault 会把正常文字粘贴吃掉(这类实现最常见的一处自伤)。
2623
+ ⚠️ 拖拽的 dragenter / dragover 必须 preventDefault:否则浏览器默认行为是**直接打开
2624
+ 这个文件**,整个页面会被替换成一张图片(比粘贴更容易踩、也更难排查)。 */
2625
+ function clipImageFiles(dt) {
2626
+ var out = [];
2627
+ if (!dt) return out;
2628
+ var items = dt.items;
2629
+ if (items && items.length) {
2630
+ for (var i = 0; i < items.length; i++) {
2631
+ var it = items[i];
2632
+ if (!it || it.kind !== 'file') continue; // 非 file 项 getAsFile() 恒为 null
2633
+ if (!/^image\//i.test(it.type || '')) continue;
2634
+ var f = it.getAsFile();
2635
+ if (f && f.size) out.push(f);
2636
+ }
2637
+ }
2638
+ if (!out.length && dt.files) { // 拖拽的文件在 files 里;粘贴侧只是回退
2639
+ for (var j = 0; j < dt.files.length; j++) {
2640
+ var g = dt.files[j];
2641
+ if (g && /^image\//i.test(g.type || '') && g.size) out.push(g);
2642
+ }
2643
+ }
2644
+ return out;
2645
+ }
2646
+ form.addEventListener('paste', function (e) {
2647
+ var files = clipImageFiles(e.clipboardData);
2648
+ if (!files.length) return; // 文字粘贴:不拦
2649
+ e.preventDefault();
2650
+ uploadFiles(files);
2651
+ });
2652
+ /* 拖拽过程中有些浏览器(Safari / 部分 WebView)不暴露 items 的文件项,只在 types 里给
2653
+ 'Files' —— 这时也必须放行,否则默认行为是**把整页替换成那张图**。 */
2654
+ function dtHasFiles(dt) {
2655
+ var types = (dt && dt.types) ? dt.types : null;
2656
+ if (!types) return false;
2657
+ for (var i = 0; i < types.length; i++) { if (types[i] === 'Files') return true; }
2658
+ return false;
2659
+ }
2660
+ ['dragenter', 'dragover'].forEach(function (t) {
2661
+ form.addEventListener(t, function (e) {
2662
+ if (!clipImageFiles(e.dataTransfer).length && !dtHasFiles(e.dataTransfer)) return;
2663
+ e.preventDefault(); e.stopPropagation();
2664
+ cbox.classList.add('is-drop');
2665
+ });
2666
+ });
2667
+ form.addEventListener('dragleave', function (e) {
2668
+ if (!form.contains(e.relatedTarget)) cbox.classList.remove('is-drop');
2669
+ });
2670
+ form.addEventListener('drop', function (e) {
2671
+ var files = clipImageFiles(e.dataTransfer);
2672
+ var anyFile = !!(e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length);
2673
+ cbox.classList.remove('is-drop');
2674
+ if (!files.length) {
2675
+ /* 拖进来的不是图片:明说,不留一个「什么都没发生」 */
2676
+ if (anyFile) { e.preventDefault(); e.stopPropagation(); noticeItem('只支持图片(PNG / JPEG / WebP / GIF / BMP)'); }
2677
+ return;
2678
+ }
2679
+ e.preventDefault(); e.stopPropagation();
2680
+ uploadFiles(files);
2681
+ });
1767
2682
  casc.addEventListener('click', function (e) { e.stopPropagation(); openEnginePicker(casc); });
1768
2683
  if (ctx.attachBtn) ctx.attachBtn.addEventListener('click', function (e) { e.stopPropagation(); openAttachSheet(ctx.attachBtn); });
1769
2684
  ctx.permBtn.addEventListener('click', function (e) { e.stopPropagation(); openPermissionPicker(ctx.permBtn); });
1770
2685
  ctx.sendBtn.addEventListener('click', function (e) {
1771
2686
  if (!state.busy) return; // 空闲时照常 submit
2687
+ /* 运行中**有内容** → 不打断,交给 submit 走「排队」(见 send);
2688
+ 只有「忙 + 空输入」才是停止(PC:92-composer.js 同一个判据)。 */
2689
+ var typed = '';
2690
+ try { typed = String(ctx.input.value || '').trim(); } catch (e0) { typed = ''; }
2691
+ /* 附件也算「有内容」:忙时贴了图再点发送 = 排队,不能把它当成「停止」(同 send 的闸门)。 */
2692
+ if (typed || state.plugin || (state.attachments || []).length) return;
1772
2693
  e.preventDefault();
1773
2694
  control('interrupt');
1774
2695
  });
@@ -1805,7 +2726,10 @@
1805
2726
  };
1806
2727
  window.MagicHost.model = {
1807
2728
  cur: function () { return state.model || ''; },
1808
- name: function (id) { return id ? String(id) : (state.model || ''); },
2729
+ /* 内核的角色行 / 「生成中」胶囊都走这里取显示名。**必须剥掉 `custom-local:`**
2730
+ —— 本壳没有 modelLabels,宿主回不出可读名时内核会退回 id,前缀就露出来了
2731
+ (移动端尤其明显:它没有 PC 那份 qaModelName 标签表,见 04-task-list.js)。 */
2732
+ name: function (id) { return qaModelText(id) || qaModelText(state.model); },
1809
2733
  set: function (id) { state.model = id ? String(id) : ''; syncPickers(); return true; },
1810
2734
  };
1811
2735
  window.MagicHost.toast = function (msg) { console.info('[conv] ' + String(msg == null ? '' : msg)); };
@@ -1818,11 +2742,32 @@
1818
2742
  再由 ask 的 payload.attachments 透传给 -a/--attach —— 附件是本地路径,跨机无意义。 */
1819
2743
  /* 附件视觉:图片出缩略图,其余退回文件名 chip。onRemove 为空 = 只读(用户气泡 / 历史回放)。
1820
2744
  历史附件没有本地 objectURL,靠回读地址(transport.mediaUrl);回读失败就地退回 chip,不留破图。 */
2745
+ /* 缩略图**按原图宽高比**定框(用户要求:「粘贴显示应该保持原图的宽高比」),
2746
+ 不再一律 56×56 硬裁 —— 粘贴进来的截图多是 16:9 / 竖屏长图,裁成正方形只剩中间
2747
+ 一小块(用户报障截图里那枚缩略图只剩一个蓝色加号)。
2748
+ 做法:只算一个缩放系数 s,宽高同时乘 s,所以比例天然一致;
2749
+ 小图最多放大到短边 56(不放更大,避免糊),长边仍受 148×96 的框约束。
2750
+ 早先的写法给宽、高各自 Math.max(56,…),1:2 的竖图会被掰成 56×96(0.58),
2751
+ 看着就是变形 —— 比例和最小尺寸冲突时,**比例优先**。 */
2752
+ function fitThumb(img, node) {
2753
+ var w = img.naturalWidth, h = img.naturalHeight;
2754
+ if (!w || !h) return;
2755
+ var MAX_W = 148, MAX_H = 96, MIN_SHORT = 56;
2756
+ var s = Math.min(MAX_W / w, MAX_H / h);
2757
+ if (s > 1) s = Math.min(s, Math.max(1, MIN_SHORT / Math.min(w, h)));
2758
+ node.style.width = Math.max(1, Math.round(w * s)) + 'px';
2759
+ node.style.height = Math.max(1, Math.round(h * s)) + 'px';
2760
+ }
1821
2761
  function thumbNode(a, onRemove, removeArg) {
1822
2762
  var node = el('span', 'attachthumb');
1823
2763
  var img = el('img', 'attachthumb__img');
1824
- img.src = a.url || a.remoteUrl || '';
1825
2764
  img.alt = a.name || '';
2765
+ img.onload = function () {
2766
+ /* naturalWidth 为 0 时(SVG / 解码失败)保持默认方框,别写成 0×0。 */
2767
+ if (img.naturalWidth) fitThumb(img, node);
2768
+ };
2769
+ img.src = a.url || a.remoteUrl || '';
2770
+ if (img.complete && img.naturalWidth) fitThumb(img, node);
1826
2771
  img.onerror = function () {
1827
2772
  var chip = el('span', 'attachchip');
1828
2773
  chip.appendChild(el('span', 'attachchip__n', a.name || '附件'));
@@ -1876,6 +2821,71 @@
1876
2821
  bar.hidden = !list.length;
1877
2822
  list.forEach(function (a) { bar.appendChild(attNode(a, removeAttachment)); });
1878
2823
  }
2824
+
2825
+ /* ── 插件胶囊(2026-10-21 用户:「选择插件后 对话框中要出现一个胶囊」)──
2826
+ 胶囊只在**输入框上方**表示「这一轮要用哪个 CLI」;它不参与附件那套上传流程
2827
+ (插件不是文件),发出去之后由 send 读走并清空。
2828
+ 位置刻意用独立一条 .plugbar:clearAttachments 每轮都会跑,
2829
+ 混在附件条里会被一起清掉。 */
2830
+ function setPlugin(p) {
2831
+ state.plugin = p || null;
2832
+ renderPlugin();
2833
+ syncInputHint();
2834
+ /* 选/取消插件也会改变「能不能排队发送」(只有插件、没写要求也算有内容)。 */
2835
+ if (state.busy) setSendMode('busy');
2836
+ return state.plugin;
2837
+ }
2838
+ function renderPlugin() {
2839
+ var bar = ctx.plugBar;
2840
+ if (!bar) return;
2841
+ bar.innerHTML = '';
2842
+ var p = state.plugin;
2843
+ bar.hidden = !p;
2844
+ if (!p) return;
2845
+ var chip = el('span', 'plugchip');
2846
+ chip.appendChild(svgIcon('i-terminal', 13));
2847
+ chip.appendChild(el('span', 'plugchip__n', p.cli || p.name || p.id || '插件'));
2848
+ var x = el('button', 'plugchip__x', '✕');
2849
+ x.type = 'button'; x.setAttribute('aria-label', '取消使用该插件');
2850
+ x.onclick = function () { setPlugin(null); };
2851
+ chip.appendChild(x);
2852
+ chip.title = p.installed ? ('已安装' + (p.version ? ' v' + p.version : ''))
2853
+ : '还没安装:发送后 Agent 会在桌面端提示安装';
2854
+ bar.appendChild(chip);
2855
+ }
2856
+
2857
+ /* 提示词拼装:用户写「做什么」,这里补上「用哪个 CLI、怎么调」。
2858
+ 模板来自服务端的 usePrompt(插件作者写的),组件不自己编命令 ——
2859
+ 它写的是**意图**(用这个 CLI 干活),具体命令由 Agent 在桌面端执行。
2860
+ 用户什么都没写时,退回 usePrompt 的骨架(它本身就是带空行的填空模板)。
2861
+ ⚠️ usePrompt 通常**自己就以「用 <cli> …」开头**(插件作者写的),
2862
+ 再加一行 head 会变成「用 magic-video 完成下面的任务。/用 magic-video 写一集短剧。」
2863
+ —— 用户实测会看到这句重复。所以先看它有没有点名这个 CLI,点了就直接用。 */
2864
+ function buildPluginPrompt(typed, p) {
2865
+ var cli = p.cli || p.name || p.id || '';
2866
+ var uninstalled = p.installed === false
2867
+ ? '\n(这个 CLI 还没装在桌面端:请先执行安装命令,装好再继续)' : '';
2868
+ if (!typed) {
2869
+ var tpl = String(p.usePrompt || '').trim();
2870
+ if (tpl) {
2871
+ /* 模板已点名这个 CLI → 原样用(它比我们拼的更贴这个插件的用法)。 */
2872
+ if (tpl.indexOf(cli) >= 0) return tpl + uninstalled;
2873
+ return '用 ' + cli + ' 完成下面的任务。' + uninstalled + '\n\n' + tpl;
2874
+ }
2875
+ return '用 ' + cli + ' 完成一个任务。\n要求:' + uninstalled;
2876
+ }
2877
+ return '用 ' + cli + ' 完成下面的任务。' + uninstalled + '\n\n' + typed;
2878
+ }
2879
+
2880
+ /* 输入框占位文案跟着胶囊走:有插件时提示「写要它做什么」,
2881
+ 否则还是原来那句「回复 Agent」—— 一个小提示能省掉一次「我该写什么」。 */
2882
+ function syncInputHint() {
2883
+ if (!ctx.input) return;
2884
+ var p = state.plugin;
2885
+ ctx.input.placeholder = p
2886
+ ? ('让 ' + (p.cli || p.name || '它') + ' 做什么?')
2887
+ : '回复 Agent';
2888
+ }
1879
2889
  /* 按对象身份移除(不要用下标:上传是异步的,多次渲染后下标会漂)。 */
1880
2890
  function removeAttachment(a) {
1881
2891
  var i = (state.attachments || []).indexOf(a);
@@ -1999,6 +3009,133 @@
1999
3009
  });
2000
3010
  return chain;
2001
3011
  }
3012
+ /* ── 插件(2026-10-21)────────────────────────────────────────────────
3013
+ 用户原话:「在会话加号中增加插件 可以选到这两cli」。
3014
+ 清单与命令**全部来自宿主**(magic-client 的 GET /desk/plugins,见 CLIENT-CONTRACT.md);
3015
+ 组件里不写死任何 CLI 名,也不自己拼命令行 —— 命令是由服务端那张固定表给的,
3016
+ 这里只负责把它摆出来、复制出去、以及发起执行。
3017
+ ⚠️ 宿主的 transport 没有 listPlugins(老版本)→ 连这一节都不画,
3018
+ 而不是画一张空表让人以为「没有插件」。 */
3019
+ function pluginRow(p, onPick) {
3020
+ var b = el('button', 'plgrow'); b.type = 'button';
3021
+ b.appendChild(el('span', 'plgrow__n', p.name || p.id || ''));
3022
+ b.appendChild(el('span', 'plgrow__s', p.tagline || ''));
3023
+ b.appendChild(el('span', 'plgrow__b' + (p.installed ? ' is-on' : ''),
3024
+ p.installed ? ('已安装' + (p.version ? ' v' + p.version : '')) : '未安装'));
3025
+ b.onclick = onPick;
3026
+ return b;
3027
+ }
3028
+ function renderPluginList(M) {
3029
+ M.title('插件');
3030
+ var box = el('div', 'plglist');
3031
+ box.appendChild(el('div', 'pick__cap', '读取中…'));
3032
+ M.node(box);
3033
+ transport.listPlugins().then(function (j) {
3034
+ var list = (j && Array.isArray(j.plugins)) ? j.plugins : [];
3035
+ box.innerHTML = '';
3036
+ if (!list.length) { box.appendChild(el('div', 'pick__cap', '桌面端没有返回任何插件')); return; }
3037
+ box.appendChild(el('div', 'pick__cap', '工程 CLI'));
3038
+ list.forEach(function (p) {
3039
+ box.appendChild(pluginRow(p, function () { Sheet.render(function (M2) { renderPluginDetail(M2, p); }); }));
3040
+ });
3041
+ box.appendChild(el('div', 'pick__cap', '选中即进入该 CLI 的安装 / 升级 / 用法;装好后在会话里让 Agent 调用它。'));
3042
+ }, function (e) {
3043
+ box.innerHTML = '';
3044
+ box.appendChild(el('div', 'pick__cap',
3045
+ '读取插件目录失败:' + ((e && e.message) || e) + '(需要较新的桌面端)'));
3046
+ });
3047
+ }
3048
+ /** 二级:某个插件的介绍 / 命令 / 执行 / 用它干活。 */
3049
+ function renderPluginDetail(M, p) {
3050
+ M.title(p.name || p.id || '');
3051
+ M.back('插件', function () { Sheet.render(renderPluginList); });
3052
+ if (p.tagline) M.node(el('div', 'plg__tag', p.tagline));
3053
+ if (p.note) M.node(el('div', 'plg__note', p.note));
3054
+ if (p.description) M.node(el('div', 'plg__p', p.description));
3055
+ if (p.requirements) M.node(el('div', 'plg__note', '环境要求:' + p.requirements));
3056
+
3057
+ var action = p.installed ? 'upgrade' : 'install';
3058
+ var cmd = (action === 'install' ? p.install : p.upgrade) || '';
3059
+ var label = action === 'install' ? '安装' : '升级';
3060
+
3061
+ M.node(el('div', 'pick__cap', label + '命令(在桌面端执行)'));
3062
+ M.node(el('pre', 'plg__cmd', cmd || '(服务端没有给出命令)'));
3063
+
3064
+ var log = el('pre', 'plg__log'); log.hidden = true;
3065
+ var btns = el('div', 'plg__btns');
3066
+ var go = el('button', 'plg__btn plg__btn--go', label); go.type = 'button';
3067
+ go.disabled = !cmd;
3068
+ var cp = el('button', 'plg__btn', '复制命令'); cp.type = 'button';
3069
+ cp.onclick = function () {
3070
+ if (!cmd) return;
3071
+ try {
3072
+ if (navigator.clipboard && navigator.clipboard.writeText) {
3073
+ navigator.clipboard.writeText(cmd).then(function () { noticeItem('命令已复制'); },
3074
+ function () { noticeItem('复制失败'); });
3075
+ return;
3076
+ }
3077
+ } catch (e) {}
3078
+ noticeItem('当前环境不支持复制');
3079
+ };
3080
+ go.onclick = function () {
3081
+ if (!cmd) return;
3082
+ /* 执行期间按钮锁住:安装是几十秒的事,重复点会并发跑同一个 npm install。 */
3083
+ go.disabled = true; cp.disabled = true;
3084
+ go.textContent = '正在' + label + '…';
3085
+ log.hidden = false; log.textContent = '▶ 开始' + label + '…\n';
3086
+ var push = function (s) { log.textContent += s; log.scrollTop = log.scrollHeight; };
3087
+ transport.pluginAction({ id: p.id, action: action }, {
3088
+ onStep: function (ev) { push('\n$ ' + (ev.command || '') + '\n'); },
3089
+ onLine: function (ev) { push((ev.line == null ? '' : ev.line) + '\n'); },
3090
+ onEnd: function (out) {
3091
+ go.disabled = false; cp.disabled = false;
3092
+ if (out && out.ok) {
3093
+ push('\n✔ 完成\n');
3094
+ go.textContent = '已' + label;
3095
+ /* 服务端在 end 里带回重探测后的完整信息 → 就地换掉这一行,
3096
+ 不然装完了徽标还是「未安装」,用户会以为没装上。 */
3097
+ if (out.plugin) {
3098
+ p = out.plugin;
3099
+ var nb = el('div', 'plg__note',
3100
+ '现在:' + (p.installed ? ('已安装' + (p.version ? ' v' + p.version : '')) : '未安装')
3101
+ + (p.bin ? ' · ' + p.bin : ''));
3102
+ btns.parentNode.insertBefore(nb, btns);
3103
+ go.textContent = p.installed ? '升级' : '安装';
3104
+ go.onclick = function () {
3105
+ Sheet.render(function (M2) { renderPluginDetail(M2, p); });
3106
+ };
3107
+ }
3108
+ } else {
3109
+ push('\n✘ 失败:' + ((out && (out.error || ('退出码 ' + out.code))) || '未知错误') + '\n');
3110
+ go.disabled = false; cp.disabled = false; go.textContent = '重试' + label;
3111
+ }
3112
+ },
3113
+ });
3114
+ };
3115
+ btns.appendChild(go); btns.appendChild(cp);
3116
+ M.node(btns); M.node(log);
3117
+
3118
+ /* 用起来(2026-10-21 用户改口径):**选中**它 —— 输入框上方出现一枚胶囊,
3119
+ 写清要用哪个 CLI;发送时由 send 把「用 magic-shorts 完成下面的任务」补到
3120
+ 用户那句话前面(见 buildPluginPrompt)。不预填模板了:
3121
+ 预填会把每个字都变成用户自己要删的噪音,胶囊才是「选了它」的表示。 */
3122
+ M.node(el('div', 'pick__cap', '用起来'));
3123
+ var use = el('button', 'plg__btn plg__btn--go', '使用这个插件'); use.type = 'button';
3124
+ use.onclick = function () {
3125
+ Sheet.close();
3126
+ setPlugin(p);
3127
+ ctx.input.focus();
3128
+ };
3129
+ /* ⚠️ 必须包一层 .plg__btns:.plg__btn 的 flex:1 只在 flex 容器里撑得开,
3130
+ 直接塞进抽屉正文会塌成一个小方块(按钮宽度由内容决定)。 */
3131
+ var useRow = el('div', 'plg__btns'); useRow.appendChild(use);
3132
+ M.node(useRow);
3133
+ if (p.examples && p.examples.length) M.node(el('pre', 'plg__cmd', p.examples.join('\n')));
3134
+ M.node(el('div', 'pick__cap',
3135
+ '选中后输入框上方会出现胶囊;发送时 Agent 会用它在桌面端干活'
3136
+ + (p.installed ? '' : '(还没安装,发送前先装)')));
3137
+ }
3138
+
2002
3139
  function openAttachSheet(anchor) {
2003
3140
  Sheet.open(anchor, function (M) {
2004
3141
  M.title('添加');
@@ -2013,6 +3150,16 @@
2013
3150
  tile('i-img', '照片 / 视频', function () { Sheet.close(); pickFiles('image/*,video/*', null, true, uploadFiles); });
2014
3151
  tile('i-doc', '添加文件', function () { Sheet.close(); pickFiles(null, null, true, uploadFiles); });
2015
3152
  M.node(grid);
3153
+ /* 插件:只在宿主真能给出清单时才出现(见 transport.listPlugins)—— 与下面四条
3154
+ 「暂未接入」的占位行不同,这一条是真的能选、能装、能用。 */
3155
+ if (transport && typeof transport.listPlugins === 'function') {
3156
+ var pb = el('button', 'sheetrow'); pb.type = 'button';
3157
+ pb.appendChild(svgIcon('i-terminal', 18));
3158
+ pb.appendChild(el('span', 'sheetrow__l', '插件'));
3159
+ pb.appendChild(svgIcon('i-chev', 16));
3160
+ pb.onclick = function () { Sheet.render(renderPluginList); };
3161
+ M.node(pb);
3162
+ }
2016
3163
  var rows = [['i-user', '专家', 'specialist'], ['i-spark', '技能', 'skills'], ['i-link', '连接器', 'connectors'], ['i-clock', '定时任务', 'scheduled']];
2017
3164
  rows.forEach(function (r) {
2018
3165
  var b = el('button', 'sheetrow'); b.type = 'button';
@@ -2046,18 +3193,43 @@
2046
3193
  function newTitleText() { return state.engine ? ('新会话 · ' + state.engine) : '新会话'; }
2047
3194
  function syncNewTitle() { if (ctx.titleEl && !state.session) ctx.titleEl.textContent = newTitleText(); }
2048
3195
  function newSession() {
2049
- stopWatch();
3196
+ stopWatch(); // 内含 stopFollow:上一段会话的实时尾随到此为止
3197
+ clearQueue(); // 上一段会话排队的追问不跟到新会话
2050
3198
  state.session = null; state.askId = null;
3199
+ state.meta = null; // 新会话没有清单行,别拿上一条的 state 判存活
2051
3200
  clearThread();
2052
3201
  clearAttachments(); // 新会话不继承上一段会话待发的附件
3202
+ setPlugin(null); // 插件胶囊同理:新会话从干净的输入框开始
2053
3203
  ctx.input.value = ''; autoGrow();
2054
3204
  threadEl.appendChild(empty);
2055
3205
  syncNewTitle();
2056
3206
  setTimeout(function () { ctx.input.focus(); }, 0);
2057
3207
  }
2058
3208
  function setSession(sid, s) {
3209
+ /* 同一条会话重入(移动端「返回列表 → 再点进来」的高频路径):画面就在眼前,
3210
+ 别清屏重放整段历史 —— 宿主复用的是同一个组件实例(见 client-ui-mobile 的
3211
+ mountConversation),历史与实时流都还在内存里。判据除了「同一条会话」还要
3212
+ `histSid === sid`(历史真的在这块画布里,见 loadHistory / clearThread)。
3213
+ 这里只同步外壳状态:会话标题可能在列表里被改过,引擎 / 模型要跟着清单行走。 */
3214
+ if (sid && sid === state.session && histSid === sid) {
3215
+ if (s) {
3216
+ /* 清单行的 state / alive 是「要不要尾随实时进度」的判据(见 sessionLive):
3217
+ 重入时它可能刚从 running 变 done,必须跟着更新,否则会去尾随一条已收尾的会话。 */
3218
+ state.meta = s;
3219
+ if (s.engine) { state.engine = s.engine; }
3220
+ state.model = s.model || '';
3221
+ if (ctx.titleEl) ctx.titleEl.textContent =
3222
+ (s.title || s.prompt_head) || String(sid).slice(0, 8);
3223
+ syncPickers();
3224
+ }
3225
+ return Promise.resolve(0);
3226
+ }
2059
3227
  stopWatch(); // 上一段会话的「新轮次发现」不许落进这一段
3228
+ /* 换会话:上一条会话排队的追问作废(旧会话的队列不能画进新会话的画布)。
3229
+ 提示放到清屏之后再说 —— 这里先说会被 clearThread 一起清掉。 */
3230
+ var droppedQueue = clearQueue();
2060
3231
  state.session = sid || null;
3232
+ state.meta = s || null; // 见 sessionLive:决定首载后要不要尾随实时进度
2061
3233
  if (s && s.engine) { state.engine = s.engine; }
2062
3234
  /* 模型跟引擎一起继承(2026-10-20 用户:「没有显示历史会话对应的模型 引擎显示了」)——
2063
3235
  清单行里带着这条会话真正用的 `model`(core 的 --sessions,见 session.Record),
@@ -2065,9 +3237,13 @@
2065
3237
  `t.model = s.model || ""`):空值 = 让 core 用默认档,不替用户预设。 */
2066
3238
  state.model = (s && s.model) || '';
2067
3239
  clearThread();
3240
+ /* 换到另一条会话:上一条留下的插件胶囊不跟过来(它是「这一轮用哪个 CLI」,
3241
+ 属于未发出的那一句,换了会话就该作废)。 */
3242
+ setPlugin(null);
2068
3243
  threadEl.appendChild(empty);
3244
+ if (droppedQueue) noticeItem('已切换会话,取消了 ' + droppedQueue + ' 条排队追问');
2069
3245
  /* ⚠️ 会话标题**以 core 的 `title` 为准**(2026-10-18 用户:「同一个会话 pc 端还是跟移动端
2070
- 标题不一样 需一致」)。core 的 `--sessions` 早已把「标题表的归纳结果 → prompt_head」
3246
+ 标题不一样 需一致」)。core 的 `--sessions` 早已把「标题表的归纳结果 → prompt_head」
2071
3247
  合并成一个 `title` 字段,列表页(移动端 titleOf / PC 掌天瓶的 cliRows)读的都是它;
2072
3248
  只有**这个详情页顶栏**此前优先 `prompt_head` —— 于是同一条会话:PC 显示归纳要点
2073
3249
  (如「完整复刻水世界场景」),移动端详情页显示首问原文(如「现在水世界跟参考图还不一样…」)。
@@ -2087,8 +3263,8 @@
2087
3263
  移动端上这一下会直接顶起软键盘,刚回放出来的历史被键盘挡掉大半。
2088
3264
  要打字时用户自己点输入框即可;newSession(用户主动「新建」)仍保留聚焦。 */
2089
3265
  }
2090
- function refreshEngines() {
2091
- return Promise.resolve(transport.listEngines()).then(function (list) {
3266
+ function refreshEngines(force) {
3267
+ return Promise.resolve(transport.listEngines(force)).then(function (list) {
2092
3268
  state.engines = Array.isArray(list) ? list : [];
2093
3269
  /* 记住的引擎已不在本机(卸载 / 改名 / 清单变了)→ 回首个可用。
2094
3270
  不做这步的话,带着一个跑不起来的引擎发任务,报错指向 core 而不是
@@ -2139,6 +3315,15 @@
2139
3315
  permissionLabel: function () { return tierOf(state.permission || '').label; },
2140
3316
  permissionTier: function () { return state.permission || ''; },
2141
3317
  openPermissionPicker: openPermissionPicker,
3318
+ /* 团队模式:开关与标签(宿主的运行设置面板用 openTeamPicker 弹出,
3319
+ 与权限模式同一套交互 —— 用户 2026-10-21:「跟权限模式一样也支持选择」)。
3320
+ `setTeam` 是**宿主灌进来的**,所以刻意不触发 onTeamChange ——
3321
+ 那是「用户改了」的回传口,宿主自己改完再收到自己的回调只会绕一圈。 */
3322
+ setTeam: function (on) { state.team = !!on; syncPickers(); },
3323
+ teamOn: function () { return !!state.team; },
3324
+ teamLabel: function () { return state.team ? '开启' : '关闭'; },
3325
+ openTeamPicker: openTeamPicker,
3326
+ featureOn: featureOn,
2142
3327
  setEngines: function (list) { state.engines = list || []; syncPickers(); },
2143
3328
  refreshEngines: refreshEngines,
2144
3329
  setSession: setSession,