magic-agent 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CLIENT-CONTRACT.md +51 -12
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +14 -0
  4. package/client-ui/pc/js/93-rail.js +3 -1
  5. package/client-ui/shared/conv/03-chat-message.js +20 -3
  6. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  7. package/client-ui/shared/conv/conv.css +17 -0
  8. package/client-ui/shared/conversation.js +170 -39
  9. package/client-ui/shared/transport-http.js +183 -38
  10. package/client-ui-mobile/index.html +370 -68
  11. package/client-ui-mobile/reqs.js +42 -29
  12. package/internal/agent/capability.go +8 -1
  13. package/internal/agent/claude.go +5 -3
  14. package/internal/agent/codebuddy.go +6 -2
  15. package/internal/agent/codex.go +7 -2
  16. package/internal/agent/dsh_sdk.go +8 -0
  17. package/internal/agent/dsh_sdk_test.go +17 -0
  18. package/internal/agent/engine.go +20 -0
  19. package/internal/agent/engine_test.go +52 -0
  20. package/internal/agent/llmengine.go +14 -0
  21. package/internal/agent/llmengine_test.go +24 -0
  22. package/internal/agent/llmhttp.go +6 -0
  23. package/internal/agent/llmhttp_test.go +27 -0
  24. package/internal/agent/llmmodels.go +48 -0
  25. package/internal/agent/permission.go +5 -0
  26. package/internal/cli/ask.go +32 -17
  27. package/internal/cli/requirements.go +2 -0
  28. package/internal/cli/sessionlog.go +5 -0
  29. package/internal/cli/sessionlog_user_test.go +49 -0
  30. package/internal/client/requirements.go +27 -7
  31. package/internal/client/requirements_test.go +51 -0
  32. package/internal/client/service.go +24 -2
  33. package/internal/client/service_test.go +35 -0
  34. package/internal/requirements/consolidate.go +86 -24
  35. package/internal/requirements/requirements.go +9 -0
  36. package/internal/requirements/requirements_test.go +129 -0
  37. package/internal/requirements/store.go +11 -3
  38. package/internal/suggest/suggest.go +118 -7
  39. package/internal/suggest/suggest_test.go +50 -0
  40. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  41. package/npm/apk/version.json +3 -3
  42. package/npm/dist/darwin-arm64/magic-agent +0 -0
  43. package/npm/dist/darwin-arm64/magic-client +0 -0
  44. package/npm/dist/darwin-x64/magic-agent +0 -0
  45. package/npm/dist/darwin-x64/magic-client +0 -0
  46. package/npm/dist/linux-arm64/magic-agent +0 -0
  47. package/npm/dist/linux-arm64/magic-client +0 -0
  48. package/npm/dist/linux-x64/magic-agent +0 -0
  49. package/npm/dist/linux-x64/magic-client +0 -0
  50. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  51. package/npm/dist/win32-x64/magic-client.exe +0 -0
  52. package/package.json +3 -2
@@ -31,6 +31,10 @@
31
31
  * transport: 必填,见上
32
32
  * session: 可选。传了就从这段会话开始(会拉历史),不传 = 新会话
33
33
  * engine, model, permission: 可选初始值(空 = 不传,由 core 用自己的默认)
34
+ * suggestEngine: 可选。生成「下一轮建议」用哪个引擎;**不传 / 空 = 关闭建议功能**
35
+ * (默认):不发 /desk/suggest、不画建议、不补历史建议、托管也不自动执行。
36
+ * 宿主在「我的 → 建议引擎 / 模型」里配置后传进来;改配置走 setSuggestEngine()。
37
+ * suggestModel: 可选。建议引擎下指定的模型;空 = 引擎默认(core 对 llm 会自动挑最快变体)。
34
38
  * engines: 可选。给了就用这份清单,不给则调 transport.listEngines()
35
39
  * titleEl: 可选。宿主显示会话标题的元素(组件会写「新会话 · <引擎>」这类文案)
36
40
  * showPermission: 可选。false 则不显示输入框下的「授权」选择器(权限仍可用 openPermissionPicker 打开)
@@ -71,7 +75,10 @@
71
75
  * onQueueDrop: 可选。(id) => void。用户取消了(宿主把它从磁盘删掉)。
72
76
  * hosting: 可选。初始是否开启「会话托管」(默认 false)。开启后,每个成功收尾的
73
77
  * 轮次会把第一条建议**自动发出去**(最多连续 hostingMax 轮)。
74
- * 开关的权威值在宿主(移动端「我的」→ 会话托管,落 localStorage)。
78
+ * 开关的权威值在宿主,且**按会话**:移动端落在会话页「任务运行设置」,
79
+ * 子会话没有自己的记录时沿父链继承(`magic.hosting.sessions.v1`)。
80
+ * 自动发出的那条会标成「托管发送」(机器人头像 + 小标),并随 ask
81
+ * 带 `hosted:true`;core 写进历史后重开会话也保持同一标识。
75
82
  * hostingDelay: 可选。建议画出来后等多少毫秒再自动发(默认 1200,测试可调小)。
76
83
  * hostingMax: 可选。连续自动执行的上限(默认 12);到顶自动暂停并回调宿主。
77
84
  * onHostingChange: 可选。(on, reason) => void。**组件自己暂停**托管时回调
@@ -391,6 +398,13 @@
391
398
  return d;
392
399
  }
393
400
 
401
+ /* 实例是否已被宿主销毁(2026-10-22)。
402
+ ⚠️ 必须有一个显式标记:画布是**全局唯一**的 `#qa-stream`(内核按固定 id 找),
403
+ 销毁只摘掉本实例的 wrap,**在飞的 ask / 排队 flush / 定时器并不会自己停**。
404
+ 它们一旦跑到下一拍,就会把这一轮的气泡画进**新实例**的画布、把排队那条
405
+ 替新实例发出去(新实例随后还留着那条 chip,看起来就是「已发出还在排队」)。
406
+ 所以所有「会写画面 / 会下发 / 会动队列」的入口都先看它一眼。 */
407
+ var destroyed = false;
394
408
  var state = {
395
409
  engines: opts.engines || [], engine: opts.engine || '', model: opts.model || '',
396
410
  permission: opts.permission || '', workspace: opts.workspace || '', attachments: [], session: opts.session || '', busy: false, askId: null,
@@ -459,13 +473,21 @@
459
473
  qaHadAsk = 本轮出现过提问卡(那种情况输入区已经有一张卡在等作答,
460
474
  再给「建议下一句」只会与它抢注意力 —— 跳过这一轮)。 */
461
475
  suggestSeq: 0, suggestAbort: null, suggestHistory: false, qaHadAsk: false,
476
+ /* 建议引擎 / 模型(2026-10-23 用户:在「我的」里像默认引擎那样选引擎 + 模型;
477
+ 默认不配置 = 关闭:不请求 / 不画 / 历史不补 / 托管不自动跑)。 */
478
+ suggestEngine: String(opts.suggestEngine || '').trim(),
479
+ suggestModel: String(opts.suggestEngine ? (opts.suggestModel || '') : '').trim(),
462
480
  /* 会话托管(2026-10-23 用户:「托管模式下自动按照最佳建议提交到会话中执行」):
463
481
  开着时每个成功收尾的轮次会把第一条(模型排序里的最佳)建议**自动发出去**。
464
482
  hostingRounds = 本次开启以来连续自动发了几轮(上限见 hostingMax,防跑飞);
465
483
  hostingTimer = 等待「让用户看见建议」的那一小段延时(可被任何用户动作取消)。
466
- ⚠️ 托管开关的**权威值在宿主**(移动端「我的」tab 落 localStorage),
467
- 组件只执行;自动暂停时通过 onHostingChange 通知宿主把开关也关掉。 */
468
- hosting: !!opts.hosting, hostingRounds: 0, hostingTimer: 0,
484
+ ⚠️ 托管开关的**权威值在宿主**,且按会话(移动端落在 `magic.hosting.sessions.v1`,
485
+ 子会话没记录就沿父链继承);组件只执行 —— 自动暂停时通过 onHostingChange
486
+ 通知宿主把**这条会话**的开关也关掉。 */
487
+ /* 没有建议引擎时即使宿主传了 hosting=true 也不进入托管:没有「最佳建议」可发,
488
+ 提问卡的自动答也一并关掉(用户 2026-10-23:默认不配置就不开启建议功能)。 */
489
+ hosting: !!opts.hosting && !!String(opts.suggestEngine || '').trim(),
490
+ hostingRounds: 0, hostingTimer: 0,
469
491
  /* 提问卡的托管(2026-10-23 用户:「托管碰到提问卡也一样发最佳建议继续」):
470
492
  卡一出现就等 hostingDelay,然后自动选第一项(优先「继续/下一步」措辞)交卷;
471
493
  用户点卡上任何地方 / 关掉卡 / 自己作答 → 取消这次自动答,不抢人的操作。 */
@@ -626,6 +648,9 @@
626
648
  d.appendChild(box('notice__ico', 'i-alert', 20));
627
649
  d.appendChild(el('div', 'notice__t', text));
628
650
  pushItem(d, true);
651
+ /* 返回节点:断线重连要在**同一行**上把「正在重连」改成「已恢复」,
652
+ 而不是再补一条(2026-10-24「断连时要有重试机制」—— 状态得看得见、也收得回)。 */
653
+ return d;
629
654
  }
630
655
 
631
656
  /* ⚠️ 2026-10-16(块③ 三端瘦身):**「事件 → 画面」整层已交给对话内核**(`shared/conv/*`)——
@@ -802,7 +827,8 @@
802
827
  if (state.replay) {
803
828
  state.replayT0 = replayAt(e); state.replayLastAt = state.replayT0;
804
829
  }
805
- qaRenderMine(String(e.text || ''), Array.isArray(e.attachments) ? e.attachments : []);
830
+ qaRenderMine(String(e.text || ''), Array.isArray(e.attachments) ? e.attachments : [],
831
+ { hosted: !!e.hosted });
806
832
  return;
807
833
  }
808
834
  /* 产物收集:这一轮的工具参数 / 工具输出 / notice 里出现的落盘路径(见 turnArts 注释)。
@@ -944,7 +970,9 @@
944
970
  /* `at` 是这一轮的唯一锚:老日志没有 at 时宁可不缓存,也不拿每轮都从 1 开始的
945
971
  seq 当键 —— 那会把上一轮的旧建议画到新的一轮上。 */
946
972
  if (!at) return '';
947
- return 'magic.suggest.v1.' + sid + '.' + encodeURIComponent(at) + '.' + seq;
973
+ return 'magic.suggest.v1.' + sid + '.' + encodeURIComponent(at) + '.' + seq
974
+ + '.' + encodeURIComponent(state.suggestEngine || '')
975
+ + '.' + encodeURIComponent(state.suggestModel || '');
948
976
  }
949
977
 
950
978
  function readHistorySuggest(key) {
@@ -975,6 +1003,8 @@
975
1003
  var history = !!(o && o.history);
976
1004
  var cacheKey = (o && o.cacheKey) || '';
977
1005
  clearSuggest();
1006
+ /* 默认不配置 = 建议功能关闭:连模型都不碰(历史缓存也不读,见 maybeHistorySuggest 的前置判断)。 */
1007
+ if (!state.suggestEngine) return;
978
1008
  if (state.replay) return;
979
1009
  /* 提问卡出现过的轮次:以前一律不请求(卡还在等人答,建议会抢注意力)。
980
1010
  托管开着且这一问**已经由托管自动答掉**时例外 —— 用户要的就是答完继续,
@@ -995,12 +1025,11 @@
995
1025
  Promise.resolve(transport.suggest({
996
1026
  user: String(userText == null ? '' : userText).slice(0, 4000),
997
1027
  reply: reply.slice(-16000),
998
- /* 旁路用 core 的默认 llm(与标题归纳同一档):建议只是纯文本,不该排进
999
- 当前编码引擎的队列 —— codebuddy 这类 CLI 一轮没退干净时,/desk/suggest
1000
- 会跟着它超时(2026-10-23 实测 22s 后 502,界面 20s 先放弃 = 永远没有建议)。
1001
- 不传 engine,由 core 用 DefaultEngine=llm。 */
1002
- engine: '',
1003
- model: ''
1028
+ /* 用户在「我的 → 建议引擎 / 模型」里选的那组;空值在上面已经提前 return。
1029
+ engine 有值、model 为空 = 引擎默认(llm 会挑不思考变体,约 1-2s);选 dsh/codebuddy
1030
+ 则是它们自己的无工具 JSON 通道(实测 6-10s,用户自己承担)。 */
1031
+ engine: state.suggestEngine,
1032
+ model: state.suggestModel
1004
1033
  }, { signal: ac ? ac.signal : undefined }))
1005
1034
  .then(function (res) {
1006
1035
  if (seq !== state.suggestSeq) return; // 已经开新轮 / 换会话 / 又收尾了
@@ -1028,6 +1057,12 @@
1028
1057
  /** 画建议行(最多 3 枚;空数组 = 收起,一个像素都不占)。 */
1029
1058
  function renderSuggestions(list, history) {
1030
1059
  var bar = ctx.sugBar; if (!bar) return;
1060
+ /* 建议功能关闭:任何迟到的响应都不许再画(requestSuggestions 已经挡了一次)。 */
1061
+ if (!state.suggestEngine) {
1062
+ bar.innerHTML = '';
1063
+ bar.hidden = true;
1064
+ return;
1065
+ }
1031
1066
  var hist = !!history;
1032
1067
  var items = (Array.isArray(list) ? list : [])
1033
1068
  .filter(function (s) { return typeof s === 'string' && s.trim(); })
@@ -1037,7 +1072,7 @@
1037
1072
  bar.innerHTML = '';
1038
1073
  if (!items.length) { bar.hidden = true; return; }
1039
1074
  /* 托管态先落一枚「托管中」标记:用户一眼看到「接下来会自动发」,
1040
- 点它当场暂停(与「我的」里的开关是同一个状态,只差一个更近的入口)。 */
1075
+ 点它当场暂停(与运行设置里的按会话开关是同一个状态,只差一个更近的入口)。 */
1041
1076
  if (state.hosting && !hist) {
1042
1077
  var tag = el('button', 'sugtag');
1043
1078
  tag.type = 'button';
@@ -1069,9 +1104,11 @@
1069
1104
  }
1070
1105
 
1071
1106
  /* ══════════ 会话托管(2026-10-23)══════════
1072
- 用户:「托管模式下自动按照最佳建议提交到会话中执行,是否开启在我的 tab 中可配置」。
1107
+ 用户:「托管模式下自动按照最佳建议提交到会话中执行」。同日再定:「跟团队模式一样
1108
+ 改成挂在会话上,子会话继承,不再是全局」。
1073
1109
 
1074
- 这里只做**执行**,开关的权威值在宿主(移动端「我的」→ 会话托管)。
1110
+ 这里只做**执行**,开关的权威值在宿主,且**按会话**(移动端会话页 → 任务运行设置;
1111
+ 子会话没有自己的记录时由宿主沿父链继承)。
1075
1112
  一次托管轮次的完整路径:
1076
1113
  成功收尾 → 生成建议 → 画出来(含「托管中」标记)→ 等 hostingDelay
1077
1114
  → 复核「还该发吗」→ 第一条建议直接走 fireTurn(用户气泡、beforeAsk、权限、排队全部照旧)。
@@ -1079,7 +1116,7 @@
1079
1116
  复核不是形式:延时期间用户可能已经开始打字、贴了附件、点了别的建议、
1080
1117
  切了会话、或者干脆把托管关了 —— 任何一条命中都必须放弃这次自动发送。
1081
1118
  自动暂停(失败 / 被停 / 到上限 / 用户暂停)都回调 onHostingChange,
1082
- 让宿主把「我的」里那枚开关同步关掉,不留下「界面显示开着、其实已经不跑了」的错觉。 */
1119
+ 让宿主把**这条会话**的开关同步关掉,不留下「界面显示开着、其实已经不跑了」的错觉。 */
1083
1120
 
1084
1121
  function clearHostingTimer() {
1085
1122
  if (state.hostingTimer) { clearTimeout(state.hostingTimer); state.hostingTimer = 0; }
@@ -1145,7 +1182,7 @@
1145
1182
 
1146
1183
  /** 卡一出现就排一次自动答;只在**当前会话的停靠位**里找,后台会话的卡不动。 */
1147
1184
  function scheduleHostingAsk() {
1148
- if (!state.hosting || state.replay || state.hostingAskTimer || state.hostingAskCard) return;
1185
+ if (!state.hosting || !state.suggestEngine || state.replay || state.hostingAskTimer || state.hostingAskCard) return;
1149
1186
  var dock = root.querySelector('#home-askdock');
1150
1187
  if (!dock || dock.hidden) return;
1151
1188
  var cards = dock.querySelectorAll('.qa-ask');
@@ -1202,11 +1239,14 @@
1202
1239
  }
1203
1240
  }
1204
1241
 
1205
- /** 宿主设置托管开关(用户点「我的」里的开关,或新建会话时同步初值)。
1242
+ /** 宿主设置托管开关(用户点会话页「任务运行设置」里的开关,或切换会话时同步初值)。
1206
1243
  * ⚠️ 刻意不回调 onHostingChange:那是「组件自己暂停了」的回传口,
1207
1244
  * 宿主自己改完再收到自己的回调只会绕圈(与 setTeam 同一约定)。 */
1208
1245
  function setHosting(on) {
1209
1246
  var next = !!on;
1247
+ /* 没有配置建议引擎 = 建议功能关闭:托管没有「最佳建议」可发,也没有提问卡自动答的入口。
1248
+ 宿主应先在「我的」里配引擎;这里再兜一道,避免旧状态/脚本直接打开。 */
1249
+ if (next && !state.suggestEngine) { pauseHosting('未配置建议引擎'); return; }
1210
1250
  if (next === state.hosting) return;
1211
1251
  state.hosting = next;
1212
1252
  state.hostingRounds = 0;
@@ -1215,13 +1255,27 @@
1215
1255
  clearHostingAsk();
1216
1256
  }
1217
1257
 
1258
+ /** 宿主改「建议引擎」:空 = 关闭建议功能。
1259
+ * 关闭时立刻清掉建议行 / 在途请求 / 托管计时器,并暂停托管(回调宿主落盘)。
1260
+ * 开启后从**下一轮**收尾开始生成;不追着已经画过的旧轮补请求。 */
1261
+ function setSuggestEngine(engine, model) {
1262
+ var next = String(engine == null ? '' : engine).trim();
1263
+ var nextModel = String(model == null ? '' : model).trim();
1264
+ if (!next) nextModel = ''; // 引擎关了就不留孤儿模型
1265
+ if (next === state.suggestEngine && nextModel === state.suggestModel) return;
1266
+ state.suggestEngine = next;
1267
+ state.suggestModel = nextModel;
1268
+ clearSuggest();
1269
+ if (!next) pauseHosting('未配置建议引擎');
1270
+ }
1271
+
1218
1272
  /** 自动发送前的复核 + 延时。text = 第一条建议(模型排序即优先级)。
1219
1273
  * 2026-10-23(用户:「排队的和自动建议发送串会话了」):这一条**只服务当前这次建议**。
1220
1274
  * 排计时器时把 sugId(会话 + 建议序号)一起拷进闭包,到点后逐条核对 ——
1221
1275
  * 会话换了 / 建议换代了 / 建议行已经没了 → **放弃这次自动发送**,绝不替别的会话开一轮。 */
1222
1276
  function scheduleHostingSend(text) {
1223
1277
  clearHostingTimer();
1224
- if (!state.hosting || !text || state.suggestHistory) return;
1278
+ if (!state.hosting || !state.suggestEngine || !text || state.suggestHistory) return;
1225
1279
  if (state.busy || state.queue.length) return; // 已经有活要干:不插队
1226
1280
  var typed = '';
1227
1281
  try { typed = String(ctx.input.value || '').trim(); } catch (e) { typed = ''; }
@@ -1261,8 +1315,9 @@
1261
1315
  state.hostingRounds++;
1262
1316
  clearSuggest(); // 建议行收起(含本条的「托管中」标记)
1263
1317
  /* 直接走 fireTurn:不经过输入框,不把这句话闪进 textarea;用户气泡、串行门、
1264
- 权限与排队语义仍由同一条路负责(与点建议、打字发送完全一致)。 */
1265
- Promise.resolve(fireTurn(String(text), [], [])).catch(function (e) {
1318
+ 权限与排队语义仍由同一条路负责(与点建议、打字发送完全一致)。
1319
+ meta.hosted = 画成「托管发送」而不是「我」,并让 history 也带这个标识。 */
1320
+ Promise.resolve(fireTurn(String(text), [], [], null, { hosted: true })).catch(function (e) {
1266
1321
  pauseHosting('自动发送失败:' + ((e && e.message) || e));
1267
1322
  });
1268
1323
  }, hostingDelay);
@@ -1271,6 +1326,9 @@
1271
1326
  /** 一轮收尾(成功 / 失败 / 被停):内核的收尾动作已经做完(收思考区 / 工具卡 / 运行胶囊),
1272
1327
  * 这一层只补**收尾行**(状态语 + 总耗时,取墙上时钟 —— 与掌天瓶那边同一口径)。 */
1273
1328
  function kernelDone(ok, label) {
1329
+ /* 销毁后迟到的终态:这一轮已经不属于任何画面了 —— 收尾行、托管判定、建议请求
1330
+ 一条都不该再跑(它们会写进新实例的画布,或替新实例发下一句)。 */
1331
+ if (destroyed) return;
1274
1332
  /* 失败 / 被用户停:托管立刻暂停。这两类收尾没有「下一步」可自动执行,
1275
1333
  继续挂着只会在下一轮突然又动起来 —— 用户很难把因果对上。 */
1276
1334
  /* ⚠️ 回放(看历史)里的失败 / 中止不算:那可能是几天前的一条旧轮次,
@@ -1447,6 +1505,7 @@
1447
1505
  }
1448
1506
 
1449
1507
  async function send() {
1508
+ if (destroyed) return; // 销毁后的实例不许再发(见 destroyed 的注释)
1450
1509
  /* 用户亲手发了一句:托管计数重新起算(手动接管意味着「我还想继续」);
1451
1510
  正在等的那次自动发送也立刻作废 —— 用户已经自己发了,不能再补一条。 */
1452
1511
  state.hostingRounds = 0;
@@ -1527,8 +1586,15 @@
1527
1586
  /* 真正开跑一轮:本地用户气泡(内核的 qaRenderMine)+ 附件快照 + runTurn。
1528
1587
  运行中排队的那条(flushQueue)也走这里 —— 两条路径画出来必须一模一样。
1529
1588
  preBubble:这条气泡**已经画过**(排队时当场画的,见 send 的排队分支)——
1530
- 传进来的话就只把它从「等待中」恢复成正常态,不再重画。 */
1531
- async function fireTurn(text, sentAtts, attPaths, preBubble) {
1589
+ 传进来的话就只把它从「等待中」恢复成正常态,不再重画。
1590
+ meta.hosted:本轮由会话托管自动发出(2026-10-23 用户:「是托管发送的要标识
1591
+ 显示时区别于“我”发送」)。传给内核画成机器人头像 + 「托管发送」小标,
1592
+ 并随 ask 下发 hosted=true,core 写进历史后回放也能认出来。
1593
+ 托管发送前已经挡掉 busy / 排队,所以这里不会与 preBubble 同时出现。 */
1594
+ async function fireTurn(text, sentAtts, attPaths, preBubble, meta) {
1595
+ /* 销毁后不许再开轮:这一笔会画进**新实例**的全局画布(见 destroyed 的注释)。 */
1596
+ if (destroyed) return;
1597
+ var hosted = !!(meta && meta.hosted);
1532
1598
  /* 新的一轮开始 = 上一轮的建议作废(它们描述的是上一轮的下一步)。 */
1533
1599
  clearSuggest();
1534
1600
  state.qaHadAsk = false;
@@ -1542,12 +1608,12 @@
1542
1608
  var bubble = preBubble || null;
1543
1609
  if (preBubble) {
1544
1610
  try { preBubble.classList.remove('is-queued'); } catch (e) {}
1545
- } else if (!state.noEcho) bubble = qaRenderMine(text, sentAtts || []) || null;
1611
+ } else if (!state.noEcho) bubble = qaRenderMine(text, sentAtts || [], { hosted: hosted }) || null;
1546
1612
  state.noEcho = false;
1547
1613
  /* 本地回声也登记签名:同一条提问随后若从补拉缓冲(网关合成的 `type:"user"`)再来一遍,
1548
1614
  或者看门狗清屏重放,就不再画出第二个一样的气泡(见 state.lastUserSig)。 */
1549
1615
  state.lastUserSig = text;
1550
- await runTurn(text, attPaths, bubble);
1616
+ await runTurn(text, attPaths, bubble, { hosted: hosted });
1551
1617
  }
1552
1618
 
1553
1619
  /* ── 排队追问:渲染 / 取消 / 收尾后自动提交 ──
@@ -1584,7 +1650,10 @@
1584
1650
  atts: (it.atts || []).slice(),
1585
1651
  session: it.sid || state.session || '', workspace: state.workspace || '',
1586
1652
  engine: state.engine, model: state.model, permission: state.permission,
1587
- team: state.team ? teamPayload() : null
1653
+ team: state.team ? teamPayload() : null,
1654
+ /* 队列里一般不会有托管条目(托管在 busy / 有队列时根本不发),
1655
+ 但真被串行门挡住时这条标记要能落库、重发时还原。 */
1656
+ hosted: !!it.hosted
1588
1657
  };
1589
1658
  }
1590
1659
  try { fn(payload); } catch (e) {}
@@ -1596,6 +1665,7 @@
1596
1665
  每条一行文本 + ✕;轮到它时由 flushQueue 从队列取走,fireTurn 再把气泡画进画布。
1597
1666
  所以同一句话**只会在一个地方出现**:排队时在上方列表,发出后才进对话区。 */
1598
1667
  function renderQueue() {
1668
+ if (destroyed) return;
1599
1669
  var bar = ctx.queueBar;
1600
1670
  if (!bar) return;
1601
1671
  bar.innerHTML = '';
@@ -1652,12 +1722,15 @@
1652
1722
 
1653
1723
  function scheduleFlush() {
1654
1724
  setTimeout(function () {
1725
+ if (destroyed) return;
1655
1726
  if (!state.busy && !state.replay) flushQueue();
1656
1727
  }, 0);
1657
1728
  }
1658
1729
 
1659
1730
  async function flushQueue() {
1660
- if (flushing || state.busy || state.replay || !state.queue.length) return;
1731
+ /* 销毁后别再动队列:取走 / 发出去 / 通知宿主 take,任意一步都会让新实例那条
1732
+ 排队 chip 变成「已发出还在显示」(见 destroyed 的注释)。 */
1733
+ if (destroyed || flushing || state.busy || state.replay || !state.queue.length) return;
1661
1734
  var it = state.queue[0];
1662
1735
  /* ⚠️ 2026-10-23(用户:「排队的和自动建议发送串会话了」):这条**不是当前会话排的**就整队作废,
1663
1736
  一条都不发 —— 队列跟会话走,实例被复用到别的会话后,把上一条会话的追问发出去就是
@@ -1676,7 +1749,7 @@
1676
1749
  /* 轮到它了:宿主那条「等待中」该换成真正跑起来的会话(见 notifyQueue)。 */
1677
1750
  notifyQueue('take', it);
1678
1751
  flushing = true;
1679
- try { await fireTurn(it.text, it.atts, it.attPaths, it.bubble); }
1752
+ try { await fireTurn(it.text, it.atts, it.attPaths, it.bubble, { hosted: !!it.hosted }); }
1680
1753
  finally { flushing = false; }
1681
1754
  /* 连排了多条:这一条也收尾之后再发下一条(让用户看得清是哪条在跑)。 */
1682
1755
  if (state.queue.length) scheduleFlush();
@@ -1697,7 +1770,9 @@
1697
1770
  条目把它记进自己的 sid —— flushQueue 发之前按它核对,不属于当前会话就整队作废。
1698
1771
  (这之前只按「宿主筛过了」信任,而宿主那一侧的匹配在 session 为空时是放行的。) */
1699
1772
  function adoptQueue(items) {
1700
- if (!Array.isArray(items) || !items.length) return 0;
1773
+ /* 销毁后的实例不许再接管:条目归宿主,等下一个活实例来认领(否则它会挂在
1774
+ 一块已经不在页面上的排队条里,谁都发不出去)。 */
1775
+ if (destroyed || !Array.isArray(items) || !items.length) return 0;
1701
1776
  var n = 0;
1702
1777
  items.forEach(function (it) {
1703
1778
  if (!it || !it.id || !it.prompt) return;
@@ -1706,7 +1781,7 @@
1706
1781
  var attPaths = (it.attachments || []).slice();
1707
1782
  /* 只入队 + 列表:不画进画布(还没发出去,画成用户气泡会让人以为已发送)。 */
1708
1783
  var item = { id: it.id, text: it.prompt, atts: atts, attPaths: attPaths, bubble: null,
1709
- sid: it.session || state.session || '' };
1784
+ sid: it.session || state.session || '', hosted: !!it.hosted };
1710
1785
  state.queue.push(item);
1711
1786
  n++;
1712
1787
  });
@@ -1719,7 +1794,9 @@
1719
1794
  return n;
1720
1795
  }
1721
1796
 
1722
- async function runTurn(prompt, attPaths, bubble) {
1797
+ async function runTurn(prompt, attPaths, bubble, meta) {
1798
+ if (destroyed) return; // 见 destroyed 的注释:销毁后不许开轮
1799
+ var hosted = !!(meta && meta.hosted);
1723
1800
  /* 本页原本在尾随别的进程跑的那一轮(follow):现在自己要发新一轮 →
1724
1801
  先把旧尾随掐掉。**不能等收尾再掐** —— 旧会话的增量会继续 render,
1725
1802
  而新一轮的事件也在同一张画布上,正文就重复了。也不走 stopFollow:
@@ -1758,6 +1835,9 @@
1758
1835
  /* team:团队开关也是这一轮的**下发参数**。宿主排队落库后重发时必须还原它,
1759
1836
  否则「开了团队模式的任务」在串行队列里会被静默降级成单任务。 */
1760
1837
  team: state.team ? teamPayload() : null,
1838
+ /* hosted:托管自动发出的那一轮,串行门落库 / 重发时要原样还原 ——
1839
+ 否则重启后补发的那条在历史里会变回「用户亲手发的」。 */
1840
+ hosted: hosted,
1761
1841
  prompt: prompt, attachments: (attPaths || []).slice(),
1762
1842
  /* queued(on):宿主**确认这一轮要等**时调它,把「排队中」标到气泡上;
1763
1843
  放行时调 queued(false)(或不调,runTurn 收口时会摘)。
@@ -1811,6 +1891,9 @@
1811
1891
  路由配置每次下发都现读宿主的配置 —— 用户在「我的」里改了图像引擎,
1812
1892
  下一条消息就该用上,不必重新挂载组件。 */
1813
1893
  if (state.team) payload.team = teamPayload();
1894
+ /* 托管自动发送:只用于历史标识(core 的 user 事件带 hosted),不改执行语义。
1895
+ 老 core 不认这个字段 / 插件没开这个能力位时会忽略,实时气泡不受影响。 */
1896
+ if (hosted) payload.hosted = true;
1814
1897
  await new Promise(function (resolve) {
1815
1898
  var resolved = false;
1816
1899
  var deliver = function () {
@@ -1906,9 +1989,11 @@
1906
1989
  };
1907
1990
  try {
1908
1991
  askHandle = transport.ask(payload, {
1909
- onOpen: function (runID) { state.askId = runID || state.askId; },
1910
- onEvent: function (ev) { try { render(ev); } catch (e) {} },
1911
- onEnd: finish
1992
+ /* 销毁后迟到的回调一律丢弃:`render` 写的是全局 #qa-stream(新实例的画布),
1993
+ `finish → hardFinish → kernelDone` 还会补收尾行、排 flush。 */
1994
+ onOpen: function (runID) { if (destroyed) return; state.askId = runID || state.askId; },
1995
+ onEvent: function (ev) { if (destroyed) return; try { render(ev); } catch (e) {} },
1996
+ onEnd: function (code) { if (destroyed) return; finish(code); }
1912
1997
  });
1913
1998
  /* 存下取消钩子:kernelDone 在 turn_end 收尾时用它掐断这条流(见那里的注释) */
1914
1999
  state.askHandle = askHandle;
@@ -2360,6 +2445,7 @@
2360
2445
  · 先读缓存(按 sid + 最后一条 user 的 at/seq),有就画,没有才请求;
2361
2446
  · 画出来的建议**不排托管**(renderSuggestions 的 history 模式)。 */
2362
2447
  function maybeHistorySuggest(sid, events, idx) {
2448
+ if (!state.suggestEngine) return; // 默认关闭:历史会话也不补建议
2363
2449
  if (!sid || idx < 0 || state.busy || state.replay || state.suggestAbort) return;
2364
2450
  /* 已经有实时建议画着(刚跑完那条会话)→ 不重复请求。 */
2365
2451
  if (ctx.sugBar && !ctx.sugBar.hidden && ctx.sugBar.querySelector('.sugchip')) return;
@@ -2548,7 +2634,9 @@
2548
2634
  function startFollow(sid, after, askID) {
2549
2635
  stopFollow();
2550
2636
  if (!sid || typeof transport.followSession !== 'function') return false;
2551
- var tok = { sid: sid, cancel: null, askId: askID || null };
2637
+ /* retryNotice = 这一轮断线时插在画布上的那行状态;重连成功后**就地把文案改掉**,
2638
+ 让它读起来是一次「已恢复」,而不是把「正在重连」永远留在历史里。 */
2639
+ var tok = { sid: sid, cancel: null, askId: askID || null, retryNotice: null };
2552
2640
  var h = transport.followSession(String(sid), after || 0, {
2553
2641
  onEvent: function (ev) {
2554
2642
  if (state.follow !== tok || sid !== state.session) return;
@@ -2566,6 +2654,23 @@
2566
2654
  state.qaClosed = false;
2567
2655
  loadHistory(sid).catch(function () { /* 下一拍由用户重开 */ });
2568
2656
  },
2657
+ /* 传输层负责重试(指数退避 + 断点游标);这里只负责**让用户看见**。
2658
+ 只在第一次退避时插一行,后续几次不重复刷屏 —— 否则一次地铁过隧道
2659
+ 能在画布里留下七八条「正在重连」。 */
2660
+ onRetry: function (info) {
2661
+ if (state.follow !== tok || sid !== state.session || tok.retryNotice) return;
2662
+ /* max_duration 是服务端主动轮换,不是「连接中断」:安静续上,别吓用户。 */
2663
+ if (info && info.reason === 'max_duration') return;
2664
+ tok.retryNotice = noticeItem('连接中断,正在自动重连…');
2665
+ },
2666
+ onResume: function () {
2667
+ if (state.follow !== tok || sid !== state.session || !tok.retryNotice) return;
2668
+ var n = tok.retryNotice;
2669
+ tok.retryNotice = null;
2670
+ var t = n && n.querySelector ? n.querySelector('.notice__t') : null;
2671
+ if (t) t.textContent = '已重新连接,继续同步';
2672
+ },
2673
+ /* 终态 / 重试用尽 / 4xx 才收摊;普通网络抖动不会走到这里(传输层在重连)。 */
2569
2674
  onEnd: function (info) { if (state.follow === tok) followEnd(info); },
2570
2675
  onError: function () { if (state.follow === tok) followEnd({ reason: 'error' }); }
2571
2676
  });
@@ -2736,6 +2841,9 @@
2736
2841
  ? histFirstPage(sid)
2737
2842
  : Promise.resolve(transport.readSession(sid, { after: after }));
2738
2843
  var done = Promise.all([firstP, asksP]).then(function (rs) {
2844
+ /* 读取期间实例被销毁(切走 / 重建):这份历史属于**上一块画布**,不能落进新实例的
2845
+ #qa-stream(全局唯一)。上一个实现只挡住了「换会话」,挡不住 destroy。 */
2846
+ if (destroyed) return 0;
2739
2847
  var r = rs[0];
2740
2848
  var events = (r && r.events) || [];
2741
2849
  /* ⚠️ **不按 seq 排序**(2026-10-16):日志是跨轮追加的,老日志里每轮的 seq
@@ -2968,13 +3076,15 @@
2968
3076
  stopFollow();
2969
3077
  }
2970
3078
  function watchTick() {
3079
+ if (destroyed) return; // 销毁后不再碰画布(见 destroyed 的注释)
2971
3080
  var sid = state.session;
2972
3081
  if (!sid || state.busy || watchInflight) return;
2973
3082
  if (typeof document !== 'undefined' && document.visibilityState && document.visibilityState !== 'visible') return;
2974
3083
  if (typeof transport.listAsks !== 'function') return;
2975
3084
  watchInflight = true;
2976
3085
  Promise.resolve().then(function () { return transport.listAsks(); }).then(function (asks) {
2977
- /* 期间换了会话:这一拍作废(新会话有自己的加载与轮询)。 */
3086
+ /* 期间换了会话 / 实例被销毁:这一拍作废(否则 clearThread 会清掉新实例的画布)。 */
3087
+ if (destroyed) return;
2978
3088
  if (sid !== state.session || state.busy) return;
2979
3089
  var run = runningAskOf(asks, sid);
2980
3090
  /* 同一条 run 只刷一次:接不上(旧网关缺 askEvents)时别每 5s 重画一遍。 */
@@ -3851,8 +3961,27 @@
3851
3961
  });
3852
3962
  }
3853
3963
  function destroy() {
3854
- clearSuggest();
3855
- stopWatch();
3964
+ /* 先把实例钉死,再收资源:顺序反了的话,收资源过程中触发的下一拍
3965
+ (流被 cancel 的收尾、flush 定时器)仍会写进全局画布 / 动队列。 */
3966
+ if (destroyed) return;
3967
+ destroyed = true;
3968
+ clearSuggest(); // 顺手 abort 在途的建议请求
3969
+ clearHostingTimer();
3970
+ clearHostingAsk();
3971
+ stopWatch(); // 巡检 + follow 一起停
3972
+ /* 掐掉本轮在飞的流:服务端拿 WithoutCancel 继续跑 core,断连只表示「这边不看了」,
3973
+ 任务照常落盘 —— 但这边**必须**断开,否则迟到的事件会画进新实例的画布。 */
3974
+ var ah = state.askHandle;
3975
+ state.askHandle = null;
3976
+ if (ah && typeof ah.cancel === 'function') { try { ah.cancel(); } catch (e) {} }
3977
+ var rs = state.resumeStop;
3978
+ state.resumeStop = null;
3979
+ if (typeof rs === 'function') { try { rs(); } catch (e) {} }
3980
+ /* 队列条目**不通知宿主 drop**:它们属于宿主的持久化队列(magic.serial.queue.v1),
3981
+ 还等着下一个活实例 adoptQueue / enqueueDispatch 接管。这里只松开本实例的引用,
3982
+ 免得一块已经不在页面上的排队条继续替自己发车。 */
3983
+ state.queue = [];
3984
+ state.busy = false;
3856
3985
  Sheet.close();
3857
3986
  if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
3858
3987
  }
@@ -3889,9 +4018,11 @@
3889
4018
  openTeamPicker: openTeamPicker,
3890
4019
  featureOn: featureOn,
3891
4020
  setEngines: function (list) { state.engines = list || []; syncPickers(); },
3892
- /* 会话托管:宿主「我的」里的开关调它;hostingOn() 给宿主做开关态回读。 */
4021
+ /* 会话托管:宿主按会话的开关调它(运行设置页);hostingOn() 给宿主做开关态回读。 */
3893
4022
  setHosting: setHosting,
3894
4023
  hostingOn: function () { return !!state.hosting; },
4024
+ /* 建议引擎:宿主「我的」里改配置时调它;空 = 关闭建议功能。 */
4025
+ setSuggestEngine: setSuggestEngine,
3895
4026
  refreshEngines: refreshEngines,
3896
4027
  setSession: setSession,
3897
4028
  newSession: newSession,