magic-agent 2.1.5 → 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 (78) hide show
  1. package/CLIENT-CONTRACT.md +147 -11
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +17 -0
  4. package/client-ui/pc/index.html +5 -0
  5. package/client-ui/pc/js/05-task-list-render.js +28 -4
  6. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  7. package/client-ui/pc/js/93-rail.js +24 -2
  8. package/client-ui/shared/conv/03-chat-message.js +63 -3
  9. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  10. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  11. package/client-ui/shared/conv/19-attachments.js +75 -11
  12. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  13. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  14. package/client-ui/shared/conv/README.md +6 -0
  15. package/client-ui/shared/conv/conv.css +33 -1
  16. package/client-ui/shared/conversation.css +42 -4
  17. package/client-ui/shared/conversation.js +801 -102
  18. package/client-ui/shared/transport-http.js +199 -38
  19. package/client-ui-mobile/index.html +731 -581
  20. package/client-ui-mobile/reqs.css +39 -0
  21. package/client-ui-mobile/reqs.js +520 -0
  22. package/cmd/magic-client/main.go +1 -1
  23. package/internal/agent/capability.go +8 -1
  24. package/internal/agent/claude.go +5 -3
  25. package/internal/agent/codebuddy.go +6 -2
  26. package/internal/agent/codex.go +7 -2
  27. package/internal/agent/dsh_sdk.go +8 -0
  28. package/internal/agent/dsh_sdk_test.go +17 -0
  29. package/internal/agent/engine.go +20 -0
  30. package/internal/agent/engine_test.go +52 -0
  31. package/internal/agent/llmengine.go +14 -0
  32. package/internal/agent/llmengine_test.go +24 -0
  33. package/internal/agent/llmhttp.go +6 -0
  34. package/internal/agent/llmhttp_test.go +27 -0
  35. package/internal/agent/llmmodels.go +48 -0
  36. package/internal/agent/permission.go +5 -0
  37. package/internal/cli/ask.go +38 -17
  38. package/internal/cli/requirements.go +2 -0
  39. package/internal/cli/root.go +5 -0
  40. package/internal/cli/session.go +16 -14
  41. package/internal/cli/sessionlog.go +5 -0
  42. package/internal/cli/sessionlog_user_test.go +49 -0
  43. package/internal/cli/suggest.go +98 -0
  44. package/internal/cli/suggest_test.go +54 -0
  45. package/internal/cli/team.go +111 -24
  46. package/internal/client/requirements.go +27 -7
  47. package/internal/client/requirements_test.go +51 -0
  48. package/internal/client/service.go +35 -3
  49. package/internal/client/service_test.go +35 -0
  50. package/internal/client/suggest.go +131 -0
  51. package/internal/client/suggest_test.go +116 -0
  52. package/internal/requirements/consolidate.go +86 -24
  53. package/internal/requirements/requirements.go +9 -0
  54. package/internal/requirements/requirements_test.go +129 -0
  55. package/internal/requirements/store.go +11 -3
  56. package/internal/session/delete.go +58 -11
  57. package/internal/session/delete_test.go +140 -0
  58. package/internal/session/session.go +25 -0
  59. package/internal/session/session_mark_test.go +62 -0
  60. package/internal/suggest/suggest.go +532 -0
  61. package/internal/suggest/suggest_test.go +268 -0
  62. package/internal/team/board.go +107 -15
  63. package/internal/team/execute.go +1 -1
  64. package/internal/team/team.go +28 -19
  65. package/internal/team/team_test.go +68 -15
  66. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  67. package/npm/apk/version.json +3 -3
  68. package/npm/dist/darwin-arm64/magic-agent +0 -0
  69. package/npm/dist/darwin-arm64/magic-client +0 -0
  70. package/npm/dist/darwin-x64/magic-agent +0 -0
  71. package/npm/dist/darwin-x64/magic-client +0 -0
  72. package/npm/dist/linux-arm64/magic-agent +0 -0
  73. package/npm/dist/linux-arm64/magic-client +0 -0
  74. package/npm/dist/linux-x64/magic-agent +0 -0
  75. package/npm/dist/linux-x64/magic-client +0 -0
  76. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  77. package/npm/dist/win32-x64/magic-client.exe +0 -0
  78. package/package.json +6 -2
@@ -19,6 +19,9 @@
19
19
  * h: {onOpen?, onEvent, onSnapshot?, onEnd?, onError?}
20
20
  * upload(file) -> Promise<{path, name, size, mime, url?}> (可选)
21
21
  * mediaUrl(path) -> string (可选)附件回读地址;有它历史气泡才画得出缩略图
22
+ * suggest({user, reply, engine, model})-> Promise<{suggestions:[string]}> (可选)
23
+ * 一轮收尾后生成「用户下一句」的快捷回复;
24
+ * 没有这条通道就不画建议(诚实降级,不造假按钮)
22
25
  * 掌天瓶走 IPC → CLI;别的对接方可能走 HTTP —— 组件对此一无所知。
23
26
  * 现成的 HTTP 实现见同目录 transport-http.js。
24
27
  *
@@ -28,6 +31,10 @@
28
31
  * transport: 必填,见上
29
32
  * session: 可选。传了就从这段会话开始(会拉历史),不传 = 新会话
30
33
  * engine, model, permission: 可选初始值(空 = 不传,由 core 用自己的默认)
34
+ * suggestEngine: 可选。生成「下一轮建议」用哪个引擎;**不传 / 空 = 关闭建议功能**
35
+ * (默认):不发 /desk/suggest、不画建议、不补历史建议、托管也不自动执行。
36
+ * 宿主在「我的 → 建议引擎 / 模型」里配置后传进来;改配置走 setSuggestEngine()。
37
+ * suggestModel: 可选。建议引擎下指定的模型;空 = 引擎默认(core 对 llm 会自动挑最快变体)。
31
38
  * engines: 可选。给了就用这份清单,不给则调 transport.listEngines()
32
39
  * titleEl: 可选。宿主显示会话标题的元素(组件会写「新会话 · <引擎>」这类文案)
33
40
  * showPermission: 可选。false 则不显示输入框下的「授权」选择器(权限仍可用 openPermissionPicker 打开)
@@ -59,9 +66,29 @@
59
66
  * **由宿主持有**(引擎/模型是宿主的偏好,不是这一轮对话的状态):
60
67
  * 组件只在每轮下发时读一次。不传 = 只传 `team:{}`,路由全走 core 默认。
61
68
  * 组件不判断「有没有配置图像引擎」这类事 —— 那是 core 的裁决(缺了就明确失败)。
69
+ * onQueuePush: 可选。(info) => void。运行中提交的追问进队时回调,宿主据此落库
70
+ * (info = {id, prompt, attachments, atts, session, workspace, engine,
71
+ * model, permission, team})。给了它,组件重建后宿主就能用
72
+ * conv.adoptQueue(…) 把还没轮到的条目还给新实例 —— 这是「重进会话
73
+ * 排队气泡消失」的正解(组件内存队列活不过 destroy)。
74
+ * onQueueTake: 可选。(id) => void。轮到这条了,正要真正下发(宿主删掉落库那条)。
75
+ * onQueueDrop: 可选。(id) => void。用户取消了(宿主把它从磁盘删掉)。
76
+ * hosting: 可选。初始是否开启「会话托管」(默认 false)。开启后,每个成功收尾的
77
+ * 轮次会把第一条建议**自动发出去**(最多连续 hostingMax 轮)。
78
+ * 开关的权威值在宿主,且**按会话**:移动端落在会话页「任务运行设置」,
79
+ * 子会话没有自己的记录时沿父链继承(`magic.hosting.sessions.v1`)。
80
+ * 自动发出的那条会标成「托管发送」(机器人头像 + 小标),并随 ask
81
+ * 带 `hosted:true`;core 写进历史后重开会话也保持同一标识。
82
+ * hostingDelay: 可选。建议画出来后等多少毫秒再自动发(默认 1200,测试可调小)。
83
+ * hostingMax: 可选。连续自动执行的上限(默认 12);到顶自动暂停并回调宿主。
84
+ * onHostingChange: 可选。(on, reason) => void。**组件自己暂停**托管时回调
85
+ * (失败 / 被停 / 到上限 / 用户点「托管中」标记);宿主据此把关掉开关、
86
+ * 同步界面并给一句原因。宿主自己调 setHosting 不触发它(避免绕圈)。
62
87
  * });
63
88
  * conv.send('…'); conv.interrupt(); conv.stop(); conv.answer('…');
64
89
  * conv.setSession(sid) / conv.newSession() / conv.refreshEngines() / conv.destroy();
90
+ * conv.setHosting(on) / conv.hostingOn() // 会话托管开关(组件只执行,权威值由宿主存)
91
+ * conv.adoptQueue([{id, prompt, attachments, atts}]) // 把宿主落库的排队条目接回画布
65
92
  *
66
93
  * ── 边界 ──
67
94
  * 组件**只管对话区**:会话列表、工作区切换、引擎探测的展示、业务视图都由对接方
@@ -371,6 +398,13 @@
371
398
  return d;
372
399
  }
373
400
 
401
+ /* 实例是否已被宿主销毁(2026-10-22)。
402
+ ⚠️ 必须有一个显式标记:画布是**全局唯一**的 `#qa-stream`(内核按固定 id 找),
403
+ 销毁只摘掉本实例的 wrap,**在飞的 ask / 排队 flush / 定时器并不会自己停**。
404
+ 它们一旦跑到下一拍,就会把这一轮的气泡画进**新实例**的画布、把排队那条
405
+ 替新实例发出去(新实例随后还留着那条 chip,看起来就是「已发出还在排队」)。
406
+ 所以所有「会写画面 / 会下发 / 会动队列」的入口都先看它一眼。 */
407
+ var destroyed = false;
374
408
  var state = {
375
409
  engines: opts.engines || [], engine: opts.engine || '', model: opts.model || '',
376
410
  permission: opts.permission || '', workspace: opts.workspace || '', attachments: [], session: opts.session || '', busy: false, askId: null,
@@ -433,12 +467,49 @@
433
467
  awaitingAnswer: false,
434
468
  /* 内核「重试」走 MagicHost.send(…, {echo:false}):那条气泡已经在画布上了,别再画一遍 */
435
469
  noEcho: false,
470
+ /* 下一轮建议(2026-10-23):收尾后向桌面端要 0-3 条「用户下一句」,点一下即发。
471
+ suggestSeq = 这一轮请求的序号(响应回来时对不上就丢弃,见 requestSuggestions);
472
+ suggestAbort = 在途请求的取消钩子(开新轮 / 换会话 / 销毁时立刻断掉);
473
+ qaHadAsk = 本轮出现过提问卡(那种情况输入区已经有一张卡在等作答,
474
+ 再给「建议下一句」只会与它抢注意力 —— 跳过这一轮)。 */
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(),
480
+ /* 会话托管(2026-10-23 用户:「托管模式下自动按照最佳建议提交到会话中执行」):
481
+ 开着时每个成功收尾的轮次会把第一条(模型排序里的最佳)建议**自动发出去**。
482
+ hostingRounds = 本次开启以来连续自动发了几轮(上限见 hostingMax,防跑飞);
483
+ hostingTimer = 等待「让用户看见建议」的那一小段延时(可被任何用户动作取消)。
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,
491
+ /* 提问卡的托管(2026-10-23 用户:「托管碰到提问卡也一样发最佳建议继续」):
492
+ 卡一出现就等 hostingDelay,然后自动选第一项(优先「继续/下一步」措辞)交卷;
493
+ 用户点卡上任何地方 / 关掉卡 / 自己作答 → 取消这次自动答,不抢人的操作。 */
494
+ hostingAskTimer: 0, hostingAskCard: null, hostingAskAnswered: false,
436
495
  /* 最近一条**已画到画布上**的提问(签名 = 正文)。同一条提问会从三处再走一遍:
437
496
  发送时的本地回声 / 历史日志回放 / 网关在 ask 缓冲头合成的 `type:"user"`(seq 固定 0)。
438
497
  没有这道签名,一次提问可能画出两三个一模一样的气泡(2026-10-20 用户报「出来三个」)。 */
439
498
  lastUserSig: ''
440
499
  };
441
500
 
501
+ /* 托管的两条护栏(都可被宿主覆盖,主要给测试用):
502
+ · hostingDelay —— 建议画出来后等一小会儿再自动发,让用户看见「要发哪句」并有机会暂停;
503
+ · hostingMax —— 连续自动执行的上限。没有这条,一个「继续」接「继续」的循环能烧一整夜;
504
+ 到顶就暂停并通知宿主,不静默停。 */
505
+ var hostingDelay = 1200, hostingMax = 12;
506
+ if (opts && typeof opts.hostingDelay === 'number' && isFinite(opts.hostingDelay)) {
507
+ hostingDelay = Math.max(0, Math.min(5000, opts.hostingDelay));
508
+ }
509
+ if (opts && typeof opts.hostingMax === 'number' && isFinite(opts.hostingMax)) {
510
+ hostingMax = Math.max(1, Math.min(50, Math.floor(opts.hostingMax)));
511
+ }
512
+
442
513
  /* ══════════ markdown 渲染(**栈归内核**)══════════
443
514
  2026-10-16 阶段三:本文件以前自带一套手写解析(inlineMd / readTable / readList /
444
515
  codePanelHtml / mdToHtml,约 200 行)—— 只覆盖「agent 回复里实际会出现」的子集,
@@ -577,6 +648,9 @@
577
648
  d.appendChild(box('notice__ico', 'i-alert', 20));
578
649
  d.appendChild(el('div', 'notice__t', text));
579
650
  pushItem(d, true);
651
+ /* 返回节点:断线重连要在**同一行**上把「正在重连」改成「已恢复」,
652
+ 而不是再补一条(2026-10-24「断连时要有重试机制」—— 状态得看得见、也收得回)。 */
653
+ return d;
580
654
  }
581
655
 
582
656
  /* ⚠️ 2026-10-16(块③ 三端瘦身):**「事件 → 画面」整层已交给对话内核**(`shared/conv/*`)——
@@ -626,6 +700,9 @@
626
700
  「一条事件流 → 对话」的映射收归对话内核一份(`shared/conv/09-stream-apply.js`),
627
701
  组件这边只递事件、递画布、把回调接回自己的状态。见下面 kernelApply。 */
628
702
  kernelApply(ev);
703
+ /* 提问卡一出现就排托管自动答(卡是 kernelApply 里同步挂到停靠位的)。
704
+ 每个事件都问一次是廉价的:没有未答的卡就什么都不做,有卡且已有计时器就直接返回。 */
705
+ if (state.hosting && !state.replay) scheduleHostingAsk();
629
706
  }
630
707
 
631
708
  /* ══════════ 事件 → 画面:走内核那一份实现 ══════════
@@ -690,7 +767,10 @@
690
767
  function turnHasContent(h) {
691
768
  var el = h && h.el;
692
769
  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');
770
+ /* ⚠️ `.qa-mk` = 标记行(notice / system / turn_failed 都画成它)。漏掉它的话,
771
+ 一轮只有过程提示(团队模式的心跳、计划播报)而没有正文时,整条消息会被
772
+ dropEmptyAnswer 当「空块」撤掉 —— 历史回放里那些提示就凭空消失了。 */
773
+ return !!el.querySelector('.qa-t, .qa-think, .qa-tools, .qa-tool, .qa-ask, .qa-agent, .qa-mk, .qa-sep, .qa-img, .qa-todo-panel');
694
774
  }
695
775
  /** 只有「引擎抬头」的助手块就撤掉(否则收尾行上方留一块空白,2026-10-20 用户截图)。 */
696
776
  function dropEmptyAnswer(h) {
@@ -717,7 +797,7 @@
717
797
  if (e.model) state.model = String(e.model);
718
798
  syncPickers();
719
799
  }
720
- if (t === 'ask') state.awaitingAnswer = true; // 引擎在等这一问的答案(见 MagicHost.send)
800
+ if (t === 'ask') { state.awaitingAnswer = true; state.qaHadAsk = true; } // 引擎在等这一问的答案(见 MagicHost.send)
721
801
  /* 画布上已经有东西了 → 撤掉那句空态说明(它只在一条都还没画时该在) */
722
802
  var em = document.getElementById('qa-stream');
723
803
  var empty = em && em.querySelector('.conv__empty');
@@ -747,7 +827,8 @@
747
827
  if (state.replay) {
748
828
  state.replayT0 = replayAt(e); state.replayLastAt = state.replayT0;
749
829
  }
750
- 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 });
751
832
  return;
752
833
  }
753
834
  /* 产物收集:这一轮的工具参数 / 工具输出 / notice 里出现的落盘路径(见 turnArts 注释)。
@@ -843,10 +924,421 @@
843
924
  onDone: function (e) { kernelDone(true, (e && e.stopped) ? '已停止' : '已完成'); },
844
925
  };
845
926
  }
927
+ /* ══════════ 下一轮建议(2026-10-23)══════════
928
+ 用户:「在回复结束后 ai 自动生成下一轮的建议,点击即可发送」。
929
+ 生成在**桌面端**做(core 的 --suggest-followups,见 internal/suggest):提示词与解析口径
930
+ 只有一份,手机只拿结果。这一层只负责「什么时候问、画在哪、点了怎么发」。
931
+
932
+ · 只在**成功收尾**且没有提问卡的那一轮问(失败 / 被停 / 历史回放都不问:
933
+ 那些轮没有「下一步」可言,问了也只会拿到过期的建议);
934
+ · 一次只有一个在途请求:开新轮 / 换会话 / 销毁立刻 abort(core 侧随之杀进程),
935
+ 响应回来时用 suggestSeq 对号,对不上就丢 —— 否则上一轮的建议会画到下一轮上;
936
+ · 点击 = **直接发送**(走 MagicHost.send,与用户打字同一条路径:内核画用户气泡、
937
+ 忙时自动排队、附件闸门一致)。不做「填进输入框」两极分化 —— 用户说的是点击即发;
938
+ · 拿不到建议(接口 4xx/5xx、超时、模型给了空数组)就**什么都不画**,不拿模板凑。 */
939
+
940
+ /* 建议行属于**哪一次建议**:{ sid, seq }。
941
+ 2026-10-23(用户:「排队的和自动建议发送串会话了」):这之前建议行只按「DOM 里还有没有
942
+ 这枚按钮」存在,托管那条路径(scheduleHostingSend)的延时窗口里也只复核了 busy /
943
+ 输入框 / 附件 —— 没有一条核对「还是不是原来那条会话、还是不是同一批建议」。
944
+ 于是切会话后那 1.2s 一到,上一条会话的建议就发进了新会话(画布、session 续接一起串)。
945
+ 现在每次请求 / 渲染都把身份记在这里,自动发送前逐条核对。 */
946
+ var sugId = { sid: '', seq: -1 };
947
+
948
+ /** 清空建议行 + 断掉在途请求(序号 +1 让迟到的响应失效)。 */
949
+ function clearSuggest() {
950
+ clearHostingTimer(); // 建议行都没了,等待中的自动发送也必须作废
951
+ clearHostingAsk(); // 提问卡的自动作答同理
952
+ state.suggestSeq++; // 迟到响应的作废标记(见 requestSuggestions)
953
+ state.suggestHistory = false;
954
+ sugId = { sid: '', seq: -1 }; // 建议行都没了:托管那条也不再有任何可发的身份
955
+ var ac = state.suggestAbort;
956
+ state.suggestAbort = null;
957
+ if (ac) { try { ac.abort(); } catch (e) {} }
958
+ if (ctx.sugBar) { ctx.sugBar.innerHTML = ''; ctx.sugBar.hidden = true; }
959
+ }
960
+
961
+ /* 历史会话的“最后一轮建议”(2026-10-23 用户:「任何历史会话现在点进去都看不到建议」):
962
+ 只在打开会话、回放完最后一轮之后请求一次;结果按 (sid, 最后一条 user 的 at/seq)
963
+ 缓存在 localStorage,30 分钟内重开不重复调模型。历史建议**只展示**,绝不交给托管
964
+ 自动发 —— 否则一打开旧会话,托管会立刻把几天前的一句建议发进去。 */
965
+ var HISTORY_SUGGEST_TTL = 30 * 60 * 1000;
966
+
967
+ function historySuggestKey(sid, u) {
968
+ if (!sid || !u) return '';
969
+ var at = String(u.at || ''), seq = String(u.seq == null ? '' : u.seq);
970
+ /* `at` 是这一轮的唯一锚:老日志没有 at 时宁可不缓存,也不拿每轮都从 1 开始的
971
+ seq 当键 —— 那会把上一轮的旧建议画到新的一轮上。 */
972
+ if (!at) return '';
973
+ return 'magic.suggest.v1.' + sid + '.' + encodeURIComponent(at) + '.' + seq
974
+ + '.' + encodeURIComponent(state.suggestEngine || '')
975
+ + '.' + encodeURIComponent(state.suggestModel || '');
976
+ }
977
+
978
+ function readHistorySuggest(key) {
979
+ if (!key) return null;
980
+ try {
981
+ var raw = window.localStorage.getItem(key);
982
+ if (!raw) return null;
983
+ var o = JSON.parse(raw);
984
+ if (!o || !Array.isArray(o.items) || !o.at) return null;
985
+ if (Date.now() - Number(o.at) > HISTORY_SUGGEST_TTL) {
986
+ window.localStorage.removeItem(key);
987
+ return null;
988
+ }
989
+ return o.items.filter(function (x) { return typeof x === 'string' && x.trim(); });
990
+ } catch (e) { return null; }
991
+ }
992
+
993
+ function writeHistorySuggest(key, items) {
994
+ if (!key) return;
995
+ try {
996
+ window.localStorage.setItem(key, JSON.stringify({ at: Date.now(), items: items || [] }));
997
+ } catch (e) { /* 隐私模式 / 配额满:不缓存,下次重算,不打扰用户 */ }
998
+ }
999
+
1000
+ /** 收尾后要一次建议。失败静默(控制台留一行,便于排障)。
1001
+ * o.history = 打开历史会话时补的“最后一轮”建议;只展示,不排托管计时器。 */
1002
+ function requestSuggestions(userText, replyText, o) {
1003
+ var history = !!(o && o.history);
1004
+ var cacheKey = (o && o.cacheKey) || '';
1005
+ clearSuggest();
1006
+ /* 默认不配置 = 建议功能关闭:连模型都不碰(历史缓存也不读,见 maybeHistorySuggest 的前置判断)。 */
1007
+ if (!state.suggestEngine) return;
1008
+ if (state.replay) return;
1009
+ /* 提问卡出现过的轮次:以前一律不请求(卡还在等人答,建议会抢注意力)。
1010
+ 托管开着且这一问**已经由托管自动答掉**时例外 —— 用户要的就是答完继续,
1011
+ 下一轮的正常建议必须照常生成。手动答的卡由 fireTurn 把 qaHadAsk 清掉,不会走到这里。 */
1012
+ if (state.qaHadAsk && !(state.hosting && state.hostingAskAnswered)) return;
1013
+ /* 宿主没接这条通道(老驱动 / 测试桩)→ 如实不做,不假装有建议。 */
1014
+ if (!transport || typeof transport.suggest !== 'function') return;
1015
+ var reply = String(replyText == null ? '' : replyText).trim();
1016
+ if (reply.length < 12) return; // 一句「好的 / 已完成」没有下一步可提炼,省一次模型调用
1017
+ var sid = state.session || '';
1018
+ var seq = state.suggestSeq;
1019
+ state.suggestHistory = history;
1020
+ var ac = null;
1021
+ try { ac = new AbortController(); } catch (e) { ac = null; }
1022
+ state.suggestAbort = ac;
1023
+ /* 界面侧 20s 放弃:core 侧是 22s / 插件 25s,留出余量让服务端先把进程收掉。 */
1024
+ var timer = setTimeout(function () { if (ac) { try { ac.abort(); } catch (e) {} } }, 20000);
1025
+ Promise.resolve(transport.suggest({
1026
+ user: String(userText == null ? '' : userText).slice(0, 4000),
1027
+ reply: reply.slice(-16000),
1028
+ /* 用户在「我的 → 建议引擎 / 模型」里选的那组;空值在上面已经提前 return。
1029
+ engine 有值、model 为空 = 引擎默认(llm 会挑不思考变体,约 1-2s);选 dsh/codebuddy
1030
+ 则是它们自己的无工具 JSON 通道(实测 6-10s,用户自己承担)。 */
1031
+ engine: state.suggestEngine,
1032
+ model: state.suggestModel
1033
+ }, { signal: ac ? ac.signal : undefined }))
1034
+ .then(function (res) {
1035
+ if (seq !== state.suggestSeq) return; // 已经开新轮 / 换会话 / 又收尾了
1036
+ if (state.replay || state.busy) return;
1037
+ if ((state.session || '') !== sid) return;
1038
+ /* 身份记在**渲染之前**:renderSuggestions 里会把托管计时器排上,它要读这个身份。 */
1039
+ sugId = { sid: sid, seq: seq };
1040
+ state.suggestHistory = history;
1041
+ var items = (res && Array.isArray(res.suggestions)) ? res.suggestions : [];
1042
+ renderSuggestions(items, history);
1043
+ if (cacheKey) writeHistorySuggest(cacheKey, items);
1044
+ })
1045
+ .catch(function (e) {
1046
+ if (seq !== state.suggestSeq) return;
1047
+ /* 超时 / abort 是常态(用户已经发了下一句),不打扰;其它失败也只在控制台说一声。 */
1048
+ if (e && e.name === 'AbortError') return;
1049
+ console.info('[conv] 建议生成失败:' + ((e && e.message) || e));
1050
+ })
1051
+ .then(function () {
1052
+ clearTimeout(timer);
1053
+ if (state.suggestAbort === ac) state.suggestAbort = null;
1054
+ });
1055
+ }
1056
+
1057
+ /** 画建议行(最多 3 枚;空数组 = 收起,一个像素都不占)。 */
1058
+ function renderSuggestions(list, history) {
1059
+ var bar = ctx.sugBar; if (!bar) return;
1060
+ /* 建议功能关闭:任何迟到的响应都不许再画(requestSuggestions 已经挡了一次)。 */
1061
+ if (!state.suggestEngine) {
1062
+ bar.innerHTML = '';
1063
+ bar.hidden = true;
1064
+ return;
1065
+ }
1066
+ var hist = !!history;
1067
+ var items = (Array.isArray(list) ? list : [])
1068
+ .filter(function (s) { return typeof s === 'string' && s.trim(); })
1069
+ .slice(0, 3);
1070
+ state.suggestHistory = hist;
1071
+ bar.classList.toggle('sugbar--history', hist);
1072
+ bar.innerHTML = '';
1073
+ if (!items.length) { bar.hidden = true; return; }
1074
+ /* 托管态先落一枚「托管中」标记:用户一眼看到「接下来会自动发」,
1075
+ 点它当场暂停(与运行设置里的按会话开关是同一个状态,只差一个更近的入口)。 */
1076
+ if (state.hosting && !hist) {
1077
+ var tag = el('button', 'sugtag');
1078
+ tag.type = 'button';
1079
+ tag.textContent = '托管中';
1080
+ tag.title = '会话托管:' + Math.round(hostingDelay / 100) / 10 + ' 秒后自动发送第一条建议;点击暂停';
1081
+ tag.addEventListener('click', function () { pauseHosting(''); });
1082
+ bar.appendChild(tag);
1083
+ }
1084
+ items.forEach(function (text, i) {
1085
+ var b = el('button', 'sugchip');
1086
+ b.type = 'button';
1087
+ b.textContent = text;
1088
+ b.title = text;
1089
+ if (state.hosting && !hist && i === 0) b.classList.add('sugchip--next');
1090
+ b.addEventListener('click', function () { pickSuggestion(text); });
1091
+ bar.appendChild(b);
1092
+ });
1093
+ bar.hidden = false;
1094
+ if (state.hosting && !hist) scheduleHostingSend(items[0]);
1095
+ }
1096
+
1097
+ /** 点了建议 = 用户明确要发这句:清掉建议行,走与打字完全相同的发送路径。 */
1098
+ function pickSuggestion(text) {
1099
+ var t = String(text == null ? '' : text).trim();
1100
+ if (!t) return;
1101
+ state.hostingRounds = 0; // 用户亲手点的:托管计数重新起算
1102
+ clearSuggest();
1103
+ if (window.MagicHost && typeof window.MagicHost.send === 'function') window.MagicHost.send(t);
1104
+ }
1105
+
1106
+ /* ══════════ 会话托管(2026-10-23)══════════
1107
+ 用户:「托管模式下自动按照最佳建议提交到会话中执行」。同日再定:「跟团队模式一样
1108
+ 改成挂在会话上,子会话继承,不再是全局」。
1109
+
1110
+ 这里只做**执行**,开关的权威值在宿主,且**按会话**(移动端会话页 → 任务运行设置;
1111
+ 子会话没有自己的记录时由宿主沿父链继承)。
1112
+ 一次托管轮次的完整路径:
1113
+ 成功收尾 → 生成建议 → 画出来(含「托管中」标记)→ 等 hostingDelay
1114
+ → 复核「还该发吗」→ 第一条建议直接走 fireTurn(用户气泡、beforeAsk、权限、排队全部照旧)。
1115
+
1116
+ 复核不是形式:延时期间用户可能已经开始打字、贴了附件、点了别的建议、
1117
+ 切了会话、或者干脆把托管关了 —— 任何一条命中都必须放弃这次自动发送。
1118
+ 自动暂停(失败 / 被停 / 到上限 / 用户暂停)都回调 onHostingChange,
1119
+ 让宿主把**这条会话**的开关同步关掉,不留下「界面显示开着、其实已经不跑了」的错觉。 */
1120
+
1121
+ function clearHostingTimer() {
1122
+ if (state.hostingTimer) { clearTimeout(state.hostingTimer); state.hostingTimer = 0; }
1123
+ }
1124
+
1125
+ /** 撤掉提问卡的自动作答:清计时器 + 把卡头那枚「托管中」标签摘掉。
1126
+ * 不重置 hostingAskAnswered —— kernelDone 还要靠它决定「这一问是托管答的,可以继续出建议」。 */
1127
+ function clearHostingAsk() {
1128
+ if (state.hostingAskTimer) { clearTimeout(state.hostingAskTimer); state.hostingAskTimer = 0; }
1129
+ var card = state.hostingAskCard;
1130
+ state.hostingAskCard = null;
1131
+ if (card) {
1132
+ var tag = card.querySelector('.qa-ask-hosting');
1133
+ if (tag && tag.parentNode) tag.parentNode.removeChild(tag);
1134
+ }
1135
+ }
1136
+
1137
+ /** 「最佳」= 优先挑措辞像继续的选项;没有就第一项(模型给的顺序本来就是推荐序)。 */
1138
+ function bestAskOption(qb) {
1139
+ var opts = [].slice.call(qb.querySelectorAll('.qa-ask-opt'));
1140
+ if (!opts.length) return null;
1141
+ var good = /继续|下一步|开始|执行|确认|是|好/;
1142
+ var bad = /取消|停止|拒绝|不用|否|关闭/;
1143
+ for (var i = 0; i < opts.length; i++) {
1144
+ var lb = String(opts[i].dataset.label || opts[i].textContent || '');
1145
+ if (good.test(lb) && !bad.test(lb)) return opts[i];
1146
+ }
1147
+ return opts[0];
1148
+ }
1149
+
1150
+ /** 真正的交卷:把每一问的第一项点中,再让内核的 qaAskMark 落定。
1151
+ * 全单选时点最后一项内核会自己发;带多选时它要等 1.5s,这里 80ms 后直接收笔。 */
1152
+ function fireHostingAsk() {
1153
+ state.hostingAskTimer = 0;
1154
+ var card = state.hostingAskCard;
1155
+ if (!state.hosting || !card || !card.isConnected || card.dataset.answered === '1' || card.dataset.hostingSkip === '1') {
1156
+ clearHostingAsk();
1157
+ return;
1158
+ }
1159
+ if (state.hostingRounds >= hostingMax) {
1160
+ pauseHosting('已连续自动执行 ' + hostingMax + ' 轮,先停下来确认结果');
1161
+ return;
1162
+ }
1163
+ var qbs = [].slice.call(card.querySelectorAll('.qa-ask-qb'));
1164
+ if (!qbs.length) { clearHostingAsk(); return; }
1165
+ var picks = [];
1166
+ for (var i = 0; i < qbs.length; i++) {
1167
+ var opt = bestAskOption(qbs[i]);
1168
+ if (!opt) { clearHostingAsk(); return; } // 有一问没有选项 → 不替用户猜,留给人工
1169
+ picks.push(opt);
1170
+ }
1171
+ state.hostingRounds++;
1172
+ state.hostingAskAnswered = true;
1173
+ picks.forEach(function (opt) { try { opt.click(); } catch (e) {} });
1174
+ setTimeout(function () {
1175
+ if (state.hostingAskCard === card && card.isConnected && card.dataset.answered !== '1'
1176
+ && typeof qaAskMark === 'function' && typeof qaAskText === 'function') {
1177
+ try { qaAskMark(card, qaAskText(card)); } catch (e) {}
1178
+ }
1179
+ if (state.hostingAskCard === card) clearHostingAsk();
1180
+ }, 80);
1181
+ }
1182
+
1183
+ /** 卡一出现就排一次自动答;只在**当前会话的停靠位**里找,后台会话的卡不动。 */
1184
+ function scheduleHostingAsk() {
1185
+ if (!state.hosting || !state.suggestEngine || state.replay || state.hostingAskTimer || state.hostingAskCard) return;
1186
+ var dock = root.querySelector('#home-askdock');
1187
+ if (!dock || dock.hidden) return;
1188
+ var cards = dock.querySelectorAll('.qa-ask');
1189
+ var card = null;
1190
+ for (var i = cards.length - 1; i >= 0; i--) {
1191
+ var c = cards[i];
1192
+ if (c.dataset.answered === '1' || c.dataset.hostingSkip === '1' || !c.isConnected) continue;
1193
+ card = c; break;
1194
+ }
1195
+ if (!card) return;
1196
+ state.hostingAskAnswered = false; // 新的一张卡:还没自动答过
1197
+ if (state.hostingRounds >= hostingMax) {
1198
+ pauseHosting('已连续自动执行 ' + hostingMax + ' 轮,先停下来确认结果');
1199
+ return;
1200
+ }
1201
+ state.hostingAskCard = card;
1202
+ var hd = card.querySelector('.qa-ask-hd');
1203
+ if (hd) {
1204
+ var tag = document.createElement('button');
1205
+ tag.type = 'button';
1206
+ tag.className = 'sugtag qa-ask-hosting';
1207
+ tag.textContent = '托管中';
1208
+ tag.title = '会话托管:' + (Math.round(hostingDelay / 100) / 10) + ' 秒后自动选第一项继续;点击暂停';
1209
+ tag.addEventListener('click', function (e) {
1210
+ e.preventDefault(); e.stopPropagation();
1211
+ pauseHosting('');
1212
+ });
1213
+ hd.insertBefore(tag, hd.querySelector('.qa-ask-x') || null);
1214
+ }
1215
+ /* 用户只要碰了卡(选项 / 其他输入 / 关闭)就取消自动答,不跟人抢。 */
1216
+ card.addEventListener('pointerdown', function (e) {
1217
+ if (e.target && e.target.closest && e.target.closest('.qa-ask-hosting')) return;
1218
+ card.dataset.hostingSkip = '1';
1219
+ clearHostingAsk();
1220
+ }, { once: true });
1221
+ card.addEventListener('focusin', function () {
1222
+ card.dataset.hostingSkip = '1';
1223
+ clearHostingAsk();
1224
+ }, { once: true });
1225
+ state.hostingAskTimer = setTimeout(fireHostingAsk, hostingDelay);
1226
+ }
1227
+
1228
+ /** 暂停托管(组件内部发起):改状态 + 通知宿主 + 留下原因。 */
1229
+ function pauseHosting(reason) {
1230
+ clearHostingTimer();
1231
+ clearHostingAsk();
1232
+ state.hostingAskAnswered = false;
1233
+ var was = state.hosting;
1234
+ state.hosting = false;
1235
+ state.hostingRounds = 0;
1236
+ if (!was) return;
1237
+ if (typeof opts.onHostingChange === 'function') {
1238
+ try { opts.onHostingChange(false, String(reason || '')); } catch (e) {}
1239
+ }
1240
+ }
1241
+
1242
+ /** 宿主设置托管开关(用户点会话页「任务运行设置」里的开关,或切换会话时同步初值)。
1243
+ * ⚠️ 刻意不回调 onHostingChange:那是「组件自己暂停了」的回传口,
1244
+ * 宿主自己改完再收到自己的回调只会绕圈(与 setTeam 同一约定)。 */
1245
+ function setHosting(on) {
1246
+ var next = !!on;
1247
+ /* 没有配置建议引擎 = 建议功能关闭:托管没有「最佳建议」可发,也没有提问卡自动答的入口。
1248
+ 宿主应先在「我的」里配引擎;这里再兜一道,避免旧状态/脚本直接打开。 */
1249
+ if (next && !state.suggestEngine) { pauseHosting('未配置建议引擎'); return; }
1250
+ if (next === state.hosting) return;
1251
+ state.hosting = next;
1252
+ state.hostingRounds = 0;
1253
+ state.hostingAskAnswered = false;
1254
+ clearHostingTimer();
1255
+ clearHostingAsk();
1256
+ }
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
+
1272
+ /** 自动发送前的复核 + 延时。text = 第一条建议(模型排序即优先级)。
1273
+ * 2026-10-23(用户:「排队的和自动建议发送串会话了」):这一条**只服务当前这次建议**。
1274
+ * 排计时器时把 sugId(会话 + 建议序号)一起拷进闭包,到点后逐条核对 ——
1275
+ * 会话换了 / 建议换代了 / 建议行已经没了 → **放弃这次自动发送**,绝不替别的会话开一轮。 */
1276
+ function scheduleHostingSend(text) {
1277
+ clearHostingTimer();
1278
+ if (!state.hosting || !state.suggestEngine || !text || state.suggestHistory) return;
1279
+ if (state.busy || state.queue.length) return; // 已经有活要干:不插队
1280
+ var typed = '';
1281
+ try { typed = String(ctx.input.value || '').trim(); } catch (e) { typed = ''; }
1282
+ if (typed) { pauseHosting('输入框里有没发出去的内容'); return; }
1283
+ if ((state.attachments || []).length) { pauseHosting('输入框里还有附件'); return; }
1284
+ if (state.plugin) { pauseHosting('输入框里还选着插件'); return; }
1285
+ if (state.hostingRounds >= hostingMax) {
1286
+ pauseHosting('已连续自动执行 ' + hostingMax + ' 轮,先停下来确认结果');
1287
+ return;
1288
+ }
1289
+ var ident = { sid: sugId.sid, seq: sugId.seq };
1290
+ state.hostingTimer = setTimeout(function () {
1291
+ state.hostingTimer = 0;
1292
+ if (!state.hosting) return;
1293
+ /* ① 归属不复核就等于跨会话发送:建议是**某条会话**那一轮的下一步,
1294
+ 不是「这个组件此刻挂着的那条」的。会话变了(或建议已换代)→ 直接放弃。 */
1295
+ if (ident.sid !== sugId.sid || ident.seq !== sugId.seq) return;
1296
+ if (ident.sid && (state.session || '') !== ident.sid) return;
1297
+ /* 延时期间世界可能变了:逐条复核,任何一条命中都放弃本次发送。 */
1298
+ if (state.busy || state.queue.length) return;
1299
+ var nowTyped = '';
1300
+ try { nowTyped = String(ctx.input.value || '').trim(); } catch (e) { nowTyped = ''; }
1301
+ if (nowTyped) { pauseHosting('输入框里有没发出去的内容'); return; }
1302
+ if ((state.attachments || []).length) { pauseHosting('输入框里还有附件'); return; }
1303
+ if (state.plugin) { pauseHosting('输入框里还选着插件'); return; }
1304
+ if (state.hostingRounds >= hostingMax) {
1305
+ pauseHosting('已连续自动执行 ' + hostingMax + ' 轮,先停下来确认结果');
1306
+ return;
1307
+ }
1308
+ /* 只发「此刻真的画在建议行里」的第一条:如果空建议 / 已被新响应替换 /
1309
+ 行被藏起来,宁可这次不发,也不能拿一个用户看不见的旧句子自动开一轮。
1310
+ (2026-10-23 用户报「明明没有建议,托管却发出又写一篇」的兜底闸门。) */
1311
+ var sugbar = ctx.sugBar;
1312
+ if (!sugbar || sugbar.hidden) return;
1313
+ var live = sugbar.querySelectorAll('.sugchip');
1314
+ if (!live.length || String(live[0].textContent || '').trim() !== String(text).trim()) return;
1315
+ state.hostingRounds++;
1316
+ clearSuggest(); // 建议行收起(含本条的「托管中」标记)
1317
+ /* 直接走 fireTurn:不经过输入框,不把这句话闪进 textarea;用户气泡、串行门、
1318
+ 权限与排队语义仍由同一条路负责(与点建议、打字发送完全一致)。
1319
+ meta.hosted = 画成「托管发送」而不是「我」,并让 history 也带这个标识。 */
1320
+ Promise.resolve(fireTurn(String(text), [], [], null, { hosted: true })).catch(function (e) {
1321
+ pauseHosting('自动发送失败:' + ((e && e.message) || e));
1322
+ });
1323
+ }, hostingDelay);
1324
+ }
1325
+
846
1326
  /** 一轮收尾(成功 / 失败 / 被停):内核的收尾动作已经做完(收思考区 / 工具卡 / 运行胶囊),
847
1327
  * 这一层只补**收尾行**(状态语 + 总耗时,取墙上时钟 —— 与掌天瓶那边同一口径)。 */
848
1328
  function kernelDone(ok, label) {
1329
+ /* 销毁后迟到的终态:这一轮已经不属于任何画面了 —— 收尾行、托管判定、建议请求
1330
+ 一条都不该再跑(它们会写进新实例的画布,或替新实例发下一句)。 */
1331
+ if (destroyed) return;
1332
+ /* 失败 / 被用户停:托管立刻暂停。这两类收尾没有「下一步」可自动执行,
1333
+ 继续挂着只会在下一轮突然又动起来 —— 用户很难把因果对上。 */
1334
+ /* ⚠️ 回放(看历史)里的失败 / 中止不算:那可能是几天前的一条旧轮次,
1335
+ 翻到它就把托管关掉,用户会以为托管自己坏了。只看**这一次真的在跑**的轮次。 */
1336
+ if (!state.replay && (!ok || label === '已停止')) {
1337
+ pauseHosting(!ok ? '这一轮失败了' : '这一轮被停止');
1338
+ }
849
1339
  var q = state.qa; if (!q) return; // 幂等:终态可能来两条(turn_end 之后还有 end)
1340
+ /* 上一轮的建议随这一轮结束一起作废;新的那批在下面按结果决定要不要求。 */
1341
+ clearSuggest();
850
1342
  state.qa = null;
851
1343
  state.qaClosed = true; // 迟到事件不再开新一笔(见 state.qaClosed)
852
1344
  state.awaitingAnswer = false;
@@ -862,6 +1354,9 @@
862
1354
  不在这条消息块里,所以撤掉不丢收尾行。 */
863
1355
  dropEmptyAnswer(q.h);
864
1356
  qaRenderDone(ok, label, thread(), ms);
1357
+ /* 只有「真的跑完了」才有下一步可取:失败 / 被停 / 回放的轮次都不问。
1358
+ 请求异步进行,界面不等待 —— 结果回来再画(见 requestSuggestions)。 */
1359
+ if (ok && label !== '已停止' && !state.replay) requestSuggestions(state.lastPrompt, state.agentRaw);
865
1360
  if (opts.onBusy) { try { opts.onBusy(false); } catch (err) {} }
866
1361
  /* 收掉运行态(2026-10-20 用户:「进行中已经变了 结束的时候图没变」)。
867
1362
  这里是「这一轮真的结束了」的**唯一收口**(turn_end / error / end 都落到这里),
@@ -887,6 +1382,7 @@
887
1382
  }
888
1383
 
889
1384
  function clearThread() {
1385
+ clearSuggest(); // 换会话 / 清屏:上一轮的建议不能留给下一段画面
890
1386
  histSid = null; // 画布清空 = 历史不在场了(见 setSession 的短路)
891
1387
  /* 换会话先停掉上一轮的断线续传轮询:否则补拉回来的事件会落进新会话的画面。 */
892
1388
  if (state.resumeStop) { var rs = state.resumeStop; state.resumeStop = null; try { rs(); } catch (e) {} }
@@ -916,6 +1412,9 @@
916
1412
  state.replayT0 = 0; state.replayLastAt = 0;
917
1413
  state.resumeT0 = 0;
918
1414
  state.awaitingAnswer = false;
1415
+ state.qaHadAsk = false;
1416
+ clearHostingAsk();
1417
+ state.hostingAskAnswered = false;
919
1418
  clearWorking(); // 递带停掉秒表(只置 null 会让定时器空转)
920
1419
  state.autoFollow = true;
921
1420
  if (mdTimer) { clearTimeout(mdTimer); mdTimer = 0; }
@@ -1006,6 +1505,11 @@
1006
1505
  }
1007
1506
 
1008
1507
  async function send() {
1508
+ if (destroyed) return; // 销毁后的实例不许再发(见 destroyed 的注释)
1509
+ /* 用户亲手发了一句:托管计数重新起算(手动接管意味着「我还想继续」);
1510
+ 正在等的那次自动发送也立刻作废 —— 用户已经自己发了,不能再补一条。 */
1511
+ state.hostingRounds = 0;
1512
+ clearHostingTimer();
1009
1513
  /* 插件胶囊在场时,用户写的只是「做什么」;**真正下发的提示词**由这一层补上
1010
1514
  「用哪个 CLI 做」(见 buildPluginPrompt)。所以这里读的是原文,
1011
1515
  两个都空才拦(只有插件、没写要求时用插件的模板兜住)。 */
@@ -1042,21 +1546,18 @@
1042
1546
  /* `state.queue.length` 也算「忙」:flush 已经在路上的那一小段窗口里,
1043
1547
  新提交的必须排到队尾,不能插到前一条排队追问的前面。 */
1044
1548
  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 };
1549
+ /* 2026-10-22 用户定稿:「排队的列表应该显示在对话框上面不进对话区域」,
1550
+ 同日再收一句「排队的任务列表是挂在对话框外面不是里面」。
1551
+ 排队的那条**还没跑**,画成用户气泡会让人以为已经发出去了;它只进输入框
1552
+ **外面**上沿的 queuebar(见 renderQueue),轮到它时再由 fireTurn 正常落进画布。
1553
+ (前一天曾在画布上先画气泡,用户已明确改回这个口径。) */
1554
+ var item = { id: qid(), text: text, atts: sentAtts, attPaths: attPaths, bubble: null,
1555
+ /* 2026-10-23:条目**当场钉死归属会话**(提交这一刻的 state.session)。
1556
+ 新会话的首句排进来的那一刻 state.session 还是空串,等 core 回 session_id
1557
+ 才填上 —— 落库时再读就成了空串,接回时按空串匹配等于「谁都可能是它」。
1558
+ 见 notifyQueue / flushQueue 里按它做的核对。 */
1559
+ sid: state.session || '' };
1054
1560
  state.queue.push(item);
1055
- /* 取消入口做在**气泡上**(排队条那边不再重复列一遍,见 renderQueue):
1056
- 用户要撤的就是这一句话,按钮贴在它身上最直接。 */
1057
- if (qb) item.unmountCancel = mountQueueCancel(qb, item);
1058
- /* 气泡已经画过了,轮到它时别再画一遍。 */
1059
- state.noEcho = false;
1060
1561
  renderQueue();
1061
1562
  /* 这条队列只在内存里,刷新/关 App 就整条消失(2026-10-21 用户:
1062
1563
  「非团队的串行排队也没落库」)。交给宿主记进它那套持久化队列
@@ -1085,8 +1586,19 @@
1085
1586
  /* 真正开跑一轮:本地用户气泡(内核的 qaRenderMine)+ 附件快照 + runTurn。
1086
1587
  运行中排队的那条(flushQueue)也走这里 —— 两条路径画出来必须一模一样。
1087
1588
  preBubble:这条气泡**已经画过**(排队时当场画的,见 send 的排队分支)——
1088
- 传进来的话就只把它从「等待中」恢复成正常态,不再重画。 */
1089
- 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);
1598
+ /* 新的一轮开始 = 上一轮的建议作废(它们描述的是上一轮的下一步)。 */
1599
+ clearSuggest();
1600
+ state.qaHadAsk = false;
1601
+ state.hostingAskAnswered = false;
1090
1602
  state.lastPrompt = text;
1091
1603
  state.lastAttPaths = attPaths || []; // 记住本轮附件,重试时一起重发
1092
1604
  /* 用户那条气泡由**内核**画(qaRenderMine)—— 与另外两端逐字同一份实现;
@@ -1096,12 +1608,12 @@
1096
1608
  var bubble = preBubble || null;
1097
1609
  if (preBubble) {
1098
1610
  try { preBubble.classList.remove('is-queued'); } catch (e) {}
1099
- } else if (!state.noEcho) bubble = qaRenderMine(text, sentAtts || []) || null;
1611
+ } else if (!state.noEcho) bubble = qaRenderMine(text, sentAtts || [], { hosted: hosted }) || null;
1100
1612
  state.noEcho = false;
1101
1613
  /* 本地回声也登记签名:同一条提问随后若从补拉缓冲(网关合成的 `type:"user"`)再来一遍,
1102
1614
  或者看门狗清屏重放,就不再画出第二个一样的气泡(见 state.lastUserSig)。 */
1103
1615
  state.lastUserSig = text;
1104
- await runTurn(text, attPaths, bubble);
1616
+ await runTurn(text, attPaths, bubble, { hosted: hosted });
1105
1617
  }
1106
1618
 
1107
1619
  /* ── 排队追问:渲染 / 取消 / 收尾后自动提交 ──
@@ -1132,47 +1644,56 @@
1132
1644
  (少了 session 追问会变成新会话,少了 team 会静默降级成单任务)。 */
1133
1645
  payload = {
1134
1646
  id: it.id, prompt: it.text, attachments: (it.attPaths || []).slice(),
1135
- session: state.session || '', workspace: state.workspace || '',
1647
+ /* atts = 气泡缩略图的快照({name, remoteUrl})。落库时必须一起带上:
1648
+ 宿主重新打开会话时用 adoptQueue 把排队气泡画回来,只存本地路径的话
1649
+ 恢复出来的气泡会丢图(用户看到的是一句没有截图的话)。 */
1650
+ atts: (it.atts || []).slice(),
1651
+ session: it.sid || state.session || '', workspace: state.workspace || '',
1136
1652
  engine: state.engine, model: state.model, permission: state.permission,
1137
- team: state.team ? teamPayload() : null
1653
+ team: state.team ? teamPayload() : null,
1654
+ /* 队列里一般不会有托管条目(托管在 busy / 有队列时根本不发),
1655
+ 但真被串行门挡住时这条标记要能落库、重发时还原。 */
1656
+ hosted: !!it.hosted
1138
1657
  };
1139
1658
  }
1140
1659
  try { fn(payload); } catch (e) {}
1141
1660
  }
1142
1661
 
1143
- /* 排队条:**只在气泡画不出来时**兜底。
1144
- 正常路径下排队的那句已经作为气泡落在画布上了(见 send 的排队分支),
1145
- 再在输入框上方原样列一遍就是同一句话出现两次 —— 用户还得在两处找「取消在哪」。
1146
- 所以现在的分工是:
1147
- · 气泡(首选):一句「排队中 · 上一轮结束后自动发送」+ 一个「取消排队」按钮;
1148
- · 排队条(兜底):只有 bubble 没画成(老内核没有 qaRenderMine 返回值、
1149
- 或画布当时不在)的条目才在这里列一行,保证它们仍然可见、仍然能撤。 */
1662
+ /* 排队条 = **排队条目的唯一可见处**(2026-10-22 用户:「排队的列表应该显示在
1663
+ 对话框上面不进对话区域」;同日再定稿「排队的任务列表是挂在对话框外面不是里面」)。
1664
+ 位置在输入框**外面**的上沿(composer__box 之外的 queuebar,见 DOM 那段注释),
1665
+ 每条一行文本 + ✕;轮到它时由 flushQueue 从队列取走,fireTurn 再把气泡画进画布。
1666
+ 所以同一句话**只会在一个地方出现**:排队时在上方列表,发出后才进对话区。 */
1150
1667
  function renderQueue() {
1668
+ if (destroyed) return;
1151
1669
  var bar = ctx.queueBar;
1152
1670
  if (!bar) return;
1153
1671
  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
- });
1672
+ var list = state.queue;
1673
+ bar.hidden = !list.length;
1674
+ if (list.length) {
1675
+ bar.appendChild(el('div', 'queuebar__cap', '排队中 · 本轮结束后自动发送'));
1676
+ list.forEach(function (it, idx) {
1677
+ var row = el('div', 'queuechip');
1678
+ row.appendChild(box('queuechip__ico', 'i-clock', 13));
1679
+ var t = el('span', 'queuechip__t', it.text);
1680
+ t.title = it.text;
1681
+ row.appendChild(t);
1682
+ var x = el('button', 'queuechip__x', '✕');
1683
+ x.type = 'button';
1684
+ x.setAttribute('aria-label', '取消这条排队');
1685
+ x.onclick = function () { dropQueued(idx); };
1686
+ row.appendChild(x);
1687
+ bar.appendChild(row);
1688
+ });
1689
+ }
1690
+ /* 挂到框外后,排队条占的高度是从**画布**那边让出来的(框的尺寸不变):
1691
+ 本来贴着底看的人,条子一出现最后一行就会被挤掉半截;撤掉(收尾自动发出)时同理。
1692
+ autoFollow 时贴一次底 —— 必须在**填完内容之后**量(先量后填,撑高的那截又会被裁掉)。 */
1693
+ if (state.autoFollow) scrollBottom();
1172
1694
  }
1173
1695
 
1174
- /* 从队列里撤掉一条:气泡(画过的)与排队条(兜底项)都要清掉,
1175
- 否则画布上留着一句永远不会被回答的话。 */
1696
+ /* 从队列里撤掉一条:排队条与(历史遗留的)画布气泡都清掉,并通知宿主删掉落库记录。 */
1176
1697
  function dropQueued(idx) {
1177
1698
  if (idx < 0 || idx >= state.queue.length) return false;
1178
1699
  var gone = state.queue.splice(idx, 1)[0];
@@ -1182,39 +1703,14 @@
1182
1703
  renderQueue();
1183
1704
  return true;
1184
1705
  }
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
1706
 
1211
1707
  /* 换会话 / 新建会话:排队条目属于**上一条会话**,不能落到新会话的画布里。
1212
1708
  返回取消的条数,由调用方决定怎么如实告知用户(静默丢用户刚写的东西最难查)。 */
1213
1709
  function clearQueue() {
1214
1710
  var n = state.queue.length;
1215
1711
  if (!n) return 0;
1216
- /* 排队时气泡已经画在画布上了(见 send 的排队分支):换会话就得一并撤掉,
1217
- 否则那几句话跟着进了新会话的画布,看着像「我在这个会话里问过」。 */
1712
+ /* 排队条目只在上方 queuebar(不再画进画布);换会话时一并撤掉,
1713
+ 再通知宿主删掉对应的落库记录,否则它会跟着新会话冒出来。 */
1218
1714
  state.queue.forEach(function (it) {
1219
1715
  if (it.bubble && it.bubble.parentNode) it.bubble.parentNode.removeChild(it.bubble);
1220
1716
  notifyQueue('drop', it);
@@ -1226,28 +1722,81 @@
1226
1722
 
1227
1723
  function scheduleFlush() {
1228
1724
  setTimeout(function () {
1725
+ if (destroyed) return;
1229
1726
  if (!state.busy && !state.replay) flushQueue();
1230
1727
  }, 0);
1231
1728
  }
1232
1729
 
1233
1730
  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');
1731
+ /* 销毁后别再动队列:取走 / 发出去 / 通知宿主 take,任意一步都会让新实例那条
1732
+ 排队 chip 变成「已发出还在显示」(见 destroyed 的注释)。 */
1733
+ if (destroyed || flushing || state.busy || state.replay || !state.queue.length) return;
1734
+ var it = state.queue[0];
1735
+ /* ⚠️ 2026-10-23(用户:「排队的和自动建议发送串会话了」):这条**不是当前会话排的**就整队作废,
1736
+ 一条都不发 —— 队列跟会话走,实例被复用到别的会话后,把上一条会话的追问发出去就是
1737
+ 张冠李戴(不仅气泡画错,`session` 续接也跟着串)。正常情况下 setSession/newSession
1738
+ 已经 clearQueue 过;这里是第二道闸:接回(adoptQueue)与运行时落定的 session_id
1739
+ 之间有时间差,闸门必须自己判,不能只靠调用方记得清。 */
1740
+ if (it.sid && state.session && it.sid !== state.session) {
1741
+ var n = clearQueue();
1742
+ if (n) noticeItem('排队追问属于另一条会话,已取消 ' + n + ' 条');
1743
+ return;
1744
+ }
1745
+ state.queue.shift();
1746
+ /* 轮到它了:先把它从上方排队列表里摘掉(renderQueue 重画),再由 fireTurn
1747
+ 正常画进画布 —— 同一句话不会在列表和对话区各出现一次。 */
1240
1748
  renderQueue();
1241
1749
  /* 轮到它了:宿主那条「等待中」该换成真正跑起来的会话(见 notifyQueue)。 */
1242
1750
  notifyQueue('take', it);
1243
1751
  flushing = true;
1244
- 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 }); }
1245
1753
  finally { flushing = false; }
1246
1754
  /* 连排了多条:这一条也收尾之后再发下一条(让用户看得清是哪条在跑)。 */
1247
1755
  if (state.queue.length) scheduleFlush();
1248
1756
  }
1249
1757
 
1250
- async function runTurn(prompt, attPaths, bubble) {
1758
+ /* ── 把宿主落库的排队条目接回**输入框上方的排队列表**(2026-10-22)──
1759
+ 需求演进:排队条目**不进对话区**(用户当天定稿「排队的列表应该显示在
1760
+ 对话框上面不进对话区域」);重进会话时也要在这里看到它。
1761
+ 根因:排队条目原先只有组件内存里的 state.queue。组件被销毁重建(返回列表
1762
+ 后再进来 / 切过别的会话 / WebView 被系统回收)内存队列就没了;历史回放读的是
1763
+ **已落盘**日志,这条还没发出去,日志里当然没有 —— 于是凭空消失,宿主那条
1764
+ 持久化记录还躺在 PENDING 里没人认领。
1765
+ 现在宿主在历史画完之后把属于这条会话的条目喂回来:列进 queuebar、接回队列,
1766
+ 这一轮收尾时照常 flush。id 沿用宿主的持久化 id —— 轮到 / 取消时宿主据此对账
1767
+ (onQueueTake / onQueueDrop),不会删错行。
1768
+ items: [{id, prompt, session?, attachments:[core 路径], atts:[{name,remoteUrl}]}]
1769
+ 2026-10-23:`session` = 宿主核对过归属的那条会话(adoptPendingFor 按它筛过),
1770
+ 条目把它记进自己的 sid —— flushQueue 发之前按它核对,不属于当前会话就整队作废。
1771
+ (这之前只按「宿主筛过了」信任,而宿主那一侧的匹配在 session 为空时是放行的。) */
1772
+ function adoptQueue(items) {
1773
+ /* 销毁后的实例不许再接管:条目归宿主,等下一个活实例来认领(否则它会挂在
1774
+ 一块已经不在页面上的排队条里,谁都发不出去)。 */
1775
+ if (destroyed || !Array.isArray(items) || !items.length) return 0;
1776
+ var n = 0;
1777
+ items.forEach(function (it) {
1778
+ if (!it || !it.id || !it.prompt) return;
1779
+ if (state.queue.some(function (x) { return x.id === it.id; })) return;
1780
+ var atts = (it.atts || []).slice();
1781
+ var attPaths = (it.attachments || []).slice();
1782
+ /* 只入队 + 列表:不画进画布(还没发出去,画成用户气泡会让人以为已发送)。 */
1783
+ var item = { id: it.id, text: it.prompt, atts: atts, attPaths: attPaths, bubble: null,
1784
+ sid: it.session || state.session || '', hosted: !!it.hosted };
1785
+ state.queue.push(item);
1786
+ n++;
1787
+ });
1788
+ if (n) {
1789
+ renderQueue();
1790
+ /* 这一刻组件刚回放完历史:如果那一轮已经收尾(busy=false)就立刻发,
1791
+ 还在跑(follow 已接管 busy)则由 followEnd 的 scheduleFlush 接手。 */
1792
+ scheduleFlush();
1793
+ }
1794
+ return n;
1795
+ }
1796
+
1797
+ async function runTurn(prompt, attPaths, bubble, meta) {
1798
+ if (destroyed) return; // 见 destroyed 的注释:销毁后不许开轮
1799
+ var hosted = !!(meta && meta.hosted);
1251
1800
  /* 本页原本在尾随别的进程跑的那一轮(follow):现在自己要发新一轮 →
1252
1801
  先把旧尾随掐掉。**不能等收尾再掐** —— 旧会话的增量会继续 render,
1253
1802
  而新一轮的事件也在同一张画布上,正文就重复了。也不走 stopFollow:
@@ -1286,6 +1835,9 @@
1286
1835
  /* team:团队开关也是这一轮的**下发参数**。宿主排队落库后重发时必须还原它,
1287
1836
  否则「开了团队模式的任务」在串行队列里会被静默降级成单任务。 */
1288
1837
  team: state.team ? teamPayload() : null,
1838
+ /* hosted:托管自动发出的那一轮,串行门落库 / 重发时要原样还原 ——
1839
+ 否则重启后补发的那条在历史里会变回「用户亲手发的」。 */
1840
+ hosted: hosted,
1289
1841
  prompt: prompt, attachments: (attPaths || []).slice(),
1290
1842
  /* queued(on):宿主**确认这一轮要等**时调它,把「排队中」标到气泡上;
1291
1843
  放行时调 queued(false)(或不调,runTurn 收口时会摘)。
@@ -1339,6 +1891,9 @@
1339
1891
  路由配置每次下发都现读宿主的配置 —— 用户在「我的」里改了图像引擎,
1340
1892
  下一条消息就该用上,不必重新挂载组件。 */
1341
1893
  if (state.team) payload.team = teamPayload();
1894
+ /* 托管自动发送:只用于历史标识(core 的 user 事件带 hosted),不改执行语义。
1895
+ 老 core 不认这个字段 / 插件没开这个能力位时会忽略,实时气泡不受影响。 */
1896
+ if (hosted) payload.hosted = true;
1342
1897
  await new Promise(function (resolve) {
1343
1898
  var resolved = false;
1344
1899
  var deliver = function () {
@@ -1369,7 +1924,13 @@
1369
1924
  if (el && !el.querySelector('.qa-t, .qa-think, .qa-tools, .qa-tool, .qa-ask, .qa-agent, .qa-mk, .qa-sep, .qa-img')) {
1370
1925
  if (el.parentNode) el.parentNode.removeChild(el);
1371
1926
  }
1372
- kernelDone(false, label || (failed ? '没有收到回复 · core 异常退出' : '没有收到回复 · 连接中断'));
1927
+ /* ⚠️ 兜底收尾也要分成功/失败(2026-10-23 实测):
1928
+ 有些引擎(如 llm)只发 result 不发 turn_end,走的就是这条 hardFinish。
1929
+ 以前无条件传 false,于是「已完成」的轮次在建议门 / 托管暂停里被当成失败:
1930
+ 建议永远不请求、托管第一轮就被暂停。判据只看**明确的成功措辞**;
1931
+ 「连接中断 / core 异常退出」一律仍算失败。 */
1932
+ var good = !failed && !!label && label.indexOf('已完成') === 0;
1933
+ kernelDone(good, label || (failed ? '没有收到回复 · core 异常退出' : '没有收到回复 · 连接中断'));
1373
1934
  } else {
1374
1935
  noticeItem('本轮没有收到任何输出(连接可能已断开),请重试');
1375
1936
  }
@@ -1428,9 +1989,11 @@
1428
1989
  };
1429
1990
  try {
1430
1991
  askHandle = transport.ask(payload, {
1431
- onOpen: function (runID) { state.askId = runID || state.askId; },
1432
- onEvent: function (ev) { try { render(ev); } catch (e) {} },
1433
- 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); }
1434
1997
  });
1435
1998
  /* 存下取消钩子:kernelDone 在 turn_end 收尾时用它掐断这条流(见那里的注释) */
1436
1999
  state.askHandle = askHandle;
@@ -1851,6 +2414,55 @@
1851
2414
  return { index: idx, terminal: terminal, at: idx >= 0 ? replayAt(events[idx]) : 0 };
1852
2415
  }
1853
2416
 
2417
+ /* 历史最后一轮能不能补建议:必须是**成功收尾**、没有待答提问卡、正文够长。
2418
+ `lastTurnInfo` 只回答“有没有终态”,这里再判“终态是不是成功 + 正文在哪”。 */
2419
+ function historyTurnReply(events, idx) {
2420
+ if (idx < 0) return null;
2421
+ var end = null, result = null, failed = false, pendingAsk = false, texts = [];
2422
+ for (var i = idx + 1; i < events.length; i++) {
2423
+ var e = events[i] || {};
2424
+ var k = e.kind || e.type;
2425
+ if (k === 'ask' || k === 'permission') pendingAsk = true;
2426
+ if (k === 'turn_failed' || k === 'error') failed = true;
2427
+ if (k === 'turn_end') end = e;
2428
+ if (k === 'result') result = e;
2429
+ if (k === 'text' && e.text) texts.push(String(e.text));
2430
+ }
2431
+ if (failed || pendingAsk) return null;
2432
+ var term = end || result;
2433
+ if (!term) return null;
2434
+ var st = String(term.status || '').toLowerCase();
2435
+ if (/fail|error|stop|cancel|abort/.test(st)) return null;
2436
+ var reply = String(term.text || '').trim();
2437
+ if (reply.length < 12) reply = texts.join('').trim();
2438
+ if (reply.length < 12) return null;
2439
+ return reply;
2440
+ }
2441
+
2442
+ /* 打开历史会话时,只给**最后一轮**补一次建议:
2443
+ · 还在跑 / 刚接上实时流 → 不补(live / partial,收尾后走实时那条);
2444
+ · 最后一轮失败 / 被停 / 有提问卡 → 不补;
2445
+ · 先读缓存(按 sid + 最后一条 user 的 at/seq),有就画,没有才请求;
2446
+ · 画出来的建议**不排托管**(renderSuggestions 的 history 模式)。 */
2447
+ function maybeHistorySuggest(sid, events, idx) {
2448
+ if (!state.suggestEngine) return; // 默认关闭:历史会话也不补建议
2449
+ if (!sid || idx < 0 || state.busy || state.replay || state.suggestAbort) return;
2450
+ /* 已经有实时建议画着(刚跑完那条会话)→ 不重复请求。 */
2451
+ if (ctx.sugBar && !ctx.sugBar.hidden && ctx.sugBar.querySelector('.sugchip')) return;
2452
+ var reply = historyTurnReply(events, idx);
2453
+ if (!reply) return;
2454
+ var u = events[idx] || {};
2455
+ var key = historySuggestKey(sid, u);
2456
+ if (key) {
2457
+ var cached = readHistorySuggest(key);
2458
+ if (cached) {
2459
+ if (cached.length) renderSuggestions(cached, true);
2460
+ return;
2461
+ }
2462
+ }
2463
+ requestSuggestions(String(u.text || ''), reply, { history: true, cacheKey: key });
2464
+ }
2465
+
1854
2466
  /* 补拉路径的收口:内核的统一收尾(qaRunEnd = finish + 摘「生成中」胶囊)之后
1855
2467
  再补收尾行。为什么不能只调 kernelDone:内核的 `result` 分支**不收尾**
1856
2468
  (实时流靠网关的 SSE `end` 帧收尾),而 ask 缓冲里只有 core 的 stdout 行 ——
@@ -2022,7 +2634,9 @@
2022
2634
  function startFollow(sid, after, askID) {
2023
2635
  stopFollow();
2024
2636
  if (!sid || typeof transport.followSession !== 'function') return false;
2025
- var tok = { sid: sid, cancel: null, askId: askID || null };
2637
+ /* retryNotice = 这一轮断线时插在画布上的那行状态;重连成功后**就地把文案改掉**,
2638
+ 让它读起来是一次「已恢复」,而不是把「正在重连」永远留在历史里。 */
2639
+ var tok = { sid: sid, cancel: null, askId: askID || null, retryNotice: null };
2026
2640
  var h = transport.followSession(String(sid), after || 0, {
2027
2641
  onEvent: function (ev) {
2028
2642
  if (state.follow !== tok || sid !== state.session) return;
@@ -2040,6 +2654,23 @@
2040
2654
  state.qaClosed = false;
2041
2655
  loadHistory(sid).catch(function () { /* 下一拍由用户重开 */ });
2042
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 才收摊;普通网络抖动不会走到这里(传输层在重连)。 */
2043
2674
  onEnd: function (info) { if (state.follow === tok) followEnd(info); },
2044
2675
  onError: function () { if (state.follow === tok) followEnd({ reason: 'error' }); }
2045
2676
  });
@@ -2210,6 +2841,9 @@
2210
2841
  ? histFirstPage(sid)
2211
2842
  : Promise.resolve(transport.readSession(sid, { after: after }));
2212
2843
  var done = Promise.all([firstP, asksP]).then(function (rs) {
2844
+ /* 读取期间实例被销毁(切走 / 重建):这份历史属于**上一块画布**,不能落进新实例的
2845
+ #qa-stream(全局唯一)。上一个实现只挡住了「换会话」,挡不住 destroy。 */
2846
+ if (destroyed) return 0;
2213
2847
  var r = rs[0];
2214
2848
  var events = (r && r.events) || [];
2215
2849
  /* ⚠️ **不按 seq 排序**(2026-10-16):日志是跨轮追加的,老日志里每轮的 seq
@@ -2317,6 +2951,11 @@
2317
2951
  /* 首载结束:骨架屏撤下。一条都没画出来(空会话)→ 把空态那句还回去。
2318
2952
  ⚠️ 期间用户可能已经切走(迟到的这一份不许撤新会话的骨架屏)。 */
2319
2953
  if (after == null && sid === state.session) endHistLoading(true);
2954
+ /* 历史会话:回放完、确认真的是“已收尾且不在跑”的最后一轮 → 补一次建议。
2955
+ 只在首载做;分页拉更早 / 接续实时流都不做。 */
2956
+ if (after == null && sid === state.session && !live && !partial && info.terminal) {
2957
+ maybeHistorySuggest(sid, events, info.index);
2958
+ }
2320
2959
  return events.length;
2321
2960
  });
2322
2961
  /* 读失败也要撤骨架屏(否则它会一直挂着,看着像永远读不完)。 */
@@ -2437,13 +3076,15 @@
2437
3076
  stopFollow();
2438
3077
  }
2439
3078
  function watchTick() {
3079
+ if (destroyed) return; // 销毁后不再碰画布(见 destroyed 的注释)
2440
3080
  var sid = state.session;
2441
3081
  if (!sid || state.busy || watchInflight) return;
2442
3082
  if (typeof document !== 'undefined' && document.visibilityState && document.visibilityState !== 'visible') return;
2443
3083
  if (typeof transport.listAsks !== 'function') return;
2444
3084
  watchInflight = true;
2445
3085
  Promise.resolve().then(function () { return transport.listAsks(); }).then(function (asks) {
2446
- /* 期间换了会话:这一拍作废(新会话有自己的加载与轮询)。 */
3086
+ /* 期间换了会话 / 实例被销毁:这一拍作废(否则 clearThread 会清掉新实例的画布)。 */
3087
+ if (destroyed) return;
2447
3088
  if (sid !== state.session || state.busy) return;
2448
3089
  var run = runningAskOf(asks, sid);
2449
3090
  /* 同一条 run 只刷一次:接不上(旧网关缺 askEvents)时别每 5s 重画一遍。 */
@@ -2512,17 +3153,28 @@
2512
3153
  ctx.bottomBtn = el('span', 'tobottom');
2513
3154
 
2514
3155
  var form = el('form', 'composer');
3156
+ /* 下一轮建议的停靠位(2026-10-23):收尾后由 /desk/suggest 生成、贴在这条输入框**正上方**。
3157
+ 默认藏起来 —— 没有建议时它一个像素都不占(不预留空行,也不让输入框跳位)。 */
3158
+ var sugBar = el('div', 'sugbar'); sugBar.hidden = true;
3159
+ sugBar.setAttribute('role', 'group');
3160
+ sugBar.setAttribute('aria-label', '建议追问');
3161
+ ctx.sugBar = sugBar;
3162
+ form.appendChild(sugBar);
2515
3163
  var cbox = el('div', 'composer__box');
2516
3164
  /* 提问卡的**停靠位**:内核按固定 id 找它(`home-askdock`,见 shared/conv/06-ask-answer.js),
2517
3165
  引擎提问 / 待授权时卡钉在输入框上方。显隐与进出全由内核管,这里只给一块地。 */
2518
3166
  var askDock = el('div', 'qa-askdock'); askDock.id = 'home-askdock'; askDock.hidden = true;
2519
3167
  cbox.appendChild(askDock);
2520
- /* 排队追问的停靠位(2026-10-21):运行中提交的那几条排在这里,不进画布 ——
2521
- 它们**还没跑**,画成用户气泡会让人以为已经发出去了。位置贴着输入框上沿
2522
- (与 PC 的 qaMsgList 同一意图:「新需求排列在会话框上面」)。 */
3168
+ /* 排队追问的停靠位(2026-10-21;2026-10-22 挪到框外):运行中提交的那几条排在这里,
3169
+ 不进画布 —— 它们**还没跑**,画成用户气泡会让人以为已经发出去了。
3170
+ ⚠️ 挂在 `.composer__box` **外面**(框上沿之上,见下面 form 的 append 顺序),
3171
+ 不放进框里:用户 2026-10-22 定稿「排队的任务列表是挂在对话框外面不是里面」——
3172
+ 塞进圆角框会跟正在写的正文混成一条,看着像「已经写进这一句了」。
3173
+ PC 的 `.ag-queue` 也是同一个挂法(.ag-box 之外)。 */
2523
3174
  var queueBar = el('div', 'queuebar'); queueBar.hidden = true;
3175
+ queueBar.setAttribute('role', 'group');
3176
+ queueBar.setAttribute('aria-label', '排队中');
2524
3177
  ctx.queueBar = queueBar;
2525
- cbox.appendChild(queueBar);
2526
3178
  /* 选中的插件胶囊的**停靠位**:在附件条上方、输入框之外。
2527
3179
  ⚠️ 不塞进 .attachbar 里:那是附件的地盘,混在一起后 clearAttachments
2528
3180
  (每轮必跑)会把胶囊一起清掉,而插件是「本轮怎么干」不是附件。 */
@@ -2578,6 +3230,9 @@
2578
3230
  crow.appendChild(cleft); crow.appendChild(ctx.sendBtn);
2579
3231
  cbox.appendChild(crow);
2580
3232
  form.appendChild(trow);
3233
+ /* 排队条在**框外**、紧贴框的上沿(见上面 queueBar 的注释):排在参数行之后,
3234
+ 出现时把参数行往上顶,而不是把输入框撑高 —— 框的尺寸只由输入内容决定。 */
3235
+ form.appendChild(queueBar);
2581
3236
  form.appendChild(cbox);
2582
3237
  wrap.appendChild(form);
2583
3238
  root.appendChild(wrap);
@@ -2691,6 +3346,9 @@
2691
3346
  /* 附件也算「有内容」:忙时贴了图再点发送 = 排队,不能把它当成「停止」(同 send 的闸门)。 */
2692
3347
  if (typed || state.plugin || (state.attachments || []).length) return;
2693
3348
  e.preventDefault();
3349
+ /* 用户按了「停止」:托管当场暂停,不等收尾行 —— 否则它可能在这一轮结束的
3350
+ 同一秒自动发出下一条,看起来像「点了停止还在自己跑」。 */
3351
+ pauseHosting('这一轮被停止');
2694
3352
  control('interrupt');
2695
3353
  });
2696
3354
 
@@ -2721,6 +3379,10 @@
2721
3379
  引擎那边还在等,这一问就断了。 */
2722
3380
  if (state.awaitingAnswer) { control('answer', t); return; }
2723
3381
  state.noEcho = !!(o && o.echo === false); // 内核的重试路径:那条气泡已经在画布上了
3382
+ /* ⚠️ 显式指定会话(`o.sid`)而此刻挂着的**不是它** → 一律不发。
3383
+ 调用方(内核的重试 / 追问)给出的是「这条消息属于哪条会话」;
3384
+ 组件实例被复用到别的会话后,照旧落进当前会话就是串会话。 */
3385
+ if (o && o.sid && String(o.sid) !== String(state.session || '')) return;
2724
3386
  ctx.input.value = t;
2725
3387
  send();
2726
3388
  };
@@ -3205,13 +3867,19 @@
3205
3867
  syncNewTitle();
3206
3868
  setTimeout(function () { ctx.input.focus(); }, 0);
3207
3869
  }
3208
- function setSession(sid, s) {
3870
+ function setSession(sid, s, o) {
3209
3871
  /* 同一条会话重入(移动端「返回列表 → 再点进来」的高频路径):画面就在眼前,
3210
3872
  别清屏重放整段历史 —— 宿主复用的是同一个组件实例(见 client-ui-mobile 的
3211
3873
  mountConversation),历史与实时流都还在内存里。判据除了「同一条会话」还要
3212
3874
  `histSid === sid`(历史真的在这块画布里,见 loadHistory / clearThread)。
3213
- 这里只同步外壳状态:会话标题可能在列表里被改过,引擎 / 模型要跟着清单行走。 */
3214
- if (sid && sid === state.session && histSid === sid) {
3875
+ 这里只同步外壳状态:会话标题可能在列表里被改过,引擎 / 模型要跟着清单行走。
3876
+ ⚠️ o.same:宿主**已经确认是同一屏会话**(run_id ↔ session_id 别名,组件自己
3877
+ 不知道这层映射)。此时按字面判会落到下面的「换会话」分支 ——
3878
+ clearQueue() 把排队追问整队删掉,正是「重新进来就看不到」的病根。
3879
+ 所以宿主传 same 就只同步外壳,绝不换代。 */
3880
+ var same = !!(o && o.same);
3881
+ if (sid && ((sid === state.session && histSid === sid) || same)) {
3882
+ if (sid) state.session = sid;
3215
3883
  if (s) {
3216
3884
  /* 清单行的 state / alive 是「要不要尾随实时进度」的判据(见 sessionLive):
3217
3885
  重入时它可能刚从 running 变 done,必须跟着更新,否则会去尾随一条已收尾的会话。 */
@@ -3255,10 +3923,15 @@
3255
3923
  startWatch();
3256
3924
  /* watchInflight:这次首载期间别让 watcher 再插一次 loadHistory(大日志可能超过 5s)。 */
3257
3925
  watchInflight = true;
3258
- loadHistory(sid)
3926
+ /* 返回「历史真的画完」的那个 Promise:宿主据此决定什么时候把落库的排队条目
3927
+ adoptQueue 回来(抢在回放之前插气泡,会被 clearThread 一起清掉 —— 那正是
3928
+ 「重新进来就看不到」的另一半)。复用同一屏(上面那个分支)时它已经是 0 了,
3929
+ 宿主同样不必等。 */
3930
+ return loadHistory(sid)
3259
3931
  .catch(function (e) { noticeItem('历史加载失败:' + (e.message || e)); })
3260
3932
  .then(function () { if (sid === state.session) watchInflight = false; });
3261
3933
  }
3934
+ return Promise.resolve(0);
3262
3935
  /* 打开一条已有的回话(看历史 / 接着聊)时**不**自动把焦点塞进输入框:
3263
3936
  移动端上这一下会直接顶起软键盘,刚回放出来的历史被键盘挡掉大半。
3264
3937
  要打字时用户自己点输入框即可;newSession(用户主动「新建」)仍保留聚焦。 */
@@ -3288,7 +3961,27 @@
3288
3961
  });
3289
3962
  }
3290
3963
  function destroy() {
3291
- 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;
3292
3985
  Sheet.close();
3293
3986
  if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
3294
3987
  }
@@ -3305,8 +3998,8 @@
3305
3998
  el: root,
3306
3999
  state: state,
3307
4000
  send: send,
3308
- interrupt: function () { return control('interrupt'); },
3309
- stop: function () { return control('stop'); },
4001
+ interrupt: function () { pauseHosting('这一轮被停止'); return control('interrupt'); },
4002
+ stop: function () { pauseHosting('这一轮被停止'); return control('stop'); },
3310
4003
  answer: function (text) { return control('answer', text); },
3311
4004
  setEngine: function (n) { state.engine = n; syncPickers(); },
3312
4005
  setModel: function (n) { state.model = n; syncPickers(); },
@@ -3325,10 +4018,16 @@
3325
4018
  openTeamPicker: openTeamPicker,
3326
4019
  featureOn: featureOn,
3327
4020
  setEngines: function (list) { state.engines = list || []; syncPickers(); },
4021
+ /* 会话托管:宿主按会话的开关调它(运行设置页);hostingOn() 给宿主做开关态回读。 */
4022
+ setHosting: setHosting,
4023
+ hostingOn: function () { return !!state.hosting; },
4024
+ /* 建议引擎:宿主「我的」里改配置时调它;空 = 关闭建议功能。 */
4025
+ setSuggestEngine: setSuggestEngine,
3328
4026
  refreshEngines: refreshEngines,
3329
4027
  setSession: setSession,
3330
4028
  newSession: newSession,
3331
4029
  loadHistory: loadHistory,
4030
+ adoptQueue: adoptQueue,
3332
4031
  render: function (ev) { render(ev); },
3333
4032
  syncPickers: syncPickers,
3334
4033
  destroy: destroy