magic-agent 1.0.111 → 2.3.0

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 (55) hide show
  1. package/CLIENT-CONTRACT.md +117 -6
  2. package/client-ui/pc/app.css +3 -0
  3. package/client-ui/pc/index.html +5 -0
  4. package/client-ui/pc/js/05-task-list-render.js +28 -4
  5. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  6. package/client-ui/pc/js/93-rail.js +21 -1
  7. package/client-ui/shared/conv/03-chat-message.js +43 -0
  8. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  9. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  10. package/client-ui/shared/conv/19-attachments.js +75 -11
  11. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  12. package/client-ui/shared/conv/README.md +6 -0
  13. package/client-ui/shared/conv/conv.css +16 -1
  14. package/client-ui/shared/conversation.css +42 -4
  15. package/client-ui/shared/conversation.js +655 -87
  16. package/client-ui/shared/transport-http.js +16 -0
  17. package/client-ui-mobile/index.html +1052 -595
  18. package/client-ui-mobile/reqs.css +39 -0
  19. package/client-ui-mobile/reqs.js +507 -0
  20. package/cmd/magic-client/main.go +2 -1
  21. package/internal/cli/ask.go +6 -0
  22. package/internal/cli/root.go +5 -0
  23. package/internal/cli/session.go +16 -14
  24. package/internal/cli/suggest.go +98 -0
  25. package/internal/cli/suggest_test.go +54 -0
  26. package/internal/cli/team.go +111 -24
  27. package/internal/client/apk.go +135 -0
  28. package/internal/client/apk_test.go +99 -0
  29. package/internal/client/prefs.go +99 -0
  30. package/internal/client/service.go +202 -1
  31. package/internal/client/suggest.go +131 -0
  32. package/internal/client/suggest_test.go +116 -0
  33. package/internal/session/delete.go +58 -11
  34. package/internal/session/delete_test.go +140 -0
  35. package/internal/session/session.go +25 -0
  36. package/internal/session/session_mark_test.go +62 -0
  37. package/internal/suggest/suggest.go +421 -0
  38. package/internal/suggest/suggest_test.go +218 -0
  39. package/internal/team/board.go +107 -15
  40. package/internal/team/execute.go +1 -1
  41. package/internal/team/team.go +28 -19
  42. package/internal/team/team_test.go +68 -15
  43. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  44. package/npm/apk/version.json +5 -0
  45. package/npm/dist/darwin-arm64/magic-agent +0 -0
  46. package/npm/dist/darwin-arm64/magic-client +0 -0
  47. package/npm/dist/darwin-x64/magic-agent +0 -0
  48. package/npm/dist/darwin-x64/magic-client +0 -0
  49. package/npm/dist/linux-arm64/magic-agent +0 -0
  50. package/npm/dist/linux-arm64/magic-client +0 -0
  51. package/npm/dist/linux-x64/magic-agent +0 -0
  52. package/npm/dist/linux-x64/magic-client +0 -0
  53. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  54. package/npm/dist/win32-x64/magic-client.exe +0 -0
  55. 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
  *
@@ -59,9 +62,26 @@
59
62
  * **由宿主持有**(引擎/模型是宿主的偏好,不是这一轮对话的状态):
60
63
  * 组件只在每轮下发时读一次。不传 = 只传 `team:{}`,路由全走 core 默认。
61
64
  * 组件不判断「有没有配置图像引擎」这类事 —— 那是 core 的裁决(缺了就明确失败)。
65
+ * onQueuePush: 可选。(info) => void。运行中提交的追问进队时回调,宿主据此落库
66
+ * (info = {id, prompt, attachments, atts, session, workspace, engine,
67
+ * model, permission, team})。给了它,组件重建后宿主就能用
68
+ * conv.adoptQueue(…) 把还没轮到的条目还给新实例 —— 这是「重进会话
69
+ * 排队气泡消失」的正解(组件内存队列活不过 destroy)。
70
+ * onQueueTake: 可选。(id) => void。轮到这条了,正要真正下发(宿主删掉落库那条)。
71
+ * onQueueDrop: 可选。(id) => void。用户取消了(宿主把它从磁盘删掉)。
72
+ * hosting: 可选。初始是否开启「会话托管」(默认 false)。开启后,每个成功收尾的
73
+ * 轮次会把第一条建议**自动发出去**(最多连续 hostingMax 轮)。
74
+ * 开关的权威值在宿主(移动端「我的」→ 会话托管,落 localStorage)。
75
+ * hostingDelay: 可选。建议画出来后等多少毫秒再自动发(默认 1200,测试可调小)。
76
+ * hostingMax: 可选。连续自动执行的上限(默认 12);到顶自动暂停并回调宿主。
77
+ * onHostingChange: 可选。(on, reason) => void。**组件自己暂停**托管时回调
78
+ * (失败 / 被停 / 到上限 / 用户点「托管中」标记);宿主据此把关掉开关、
79
+ * 同步界面并给一句原因。宿主自己调 setHosting 不触发它(避免绕圈)。
62
80
  * });
63
81
  * conv.send('…'); conv.interrupt(); conv.stop(); conv.answer('…');
64
82
  * conv.setSession(sid) / conv.newSession() / conv.refreshEngines() / conv.destroy();
83
+ * conv.setHosting(on) / conv.hostingOn() // 会话托管开关(组件只执行,权威值由宿主存)
84
+ * conv.adoptQueue([{id, prompt, attachments, atts}]) // 把宿主落库的排队条目接回画布
65
85
  *
66
86
  * ── 边界 ──
67
87
  * 组件**只管对话区**:会话列表、工作区切换、引擎探测的展示、业务视图都由对接方
@@ -433,12 +453,41 @@
433
453
  awaitingAnswer: false,
434
454
  /* 内核「重试」走 MagicHost.send(…, {echo:false}):那条气泡已经在画布上了,别再画一遍 */
435
455
  noEcho: false,
456
+ /* 下一轮建议(2026-10-23):收尾后向桌面端要 0-3 条「用户下一句」,点一下即发。
457
+ suggestSeq = 这一轮请求的序号(响应回来时对不上就丢弃,见 requestSuggestions);
458
+ suggestAbort = 在途请求的取消钩子(开新轮 / 换会话 / 销毁时立刻断掉);
459
+ qaHadAsk = 本轮出现过提问卡(那种情况输入区已经有一张卡在等作答,
460
+ 再给「建议下一句」只会与它抢注意力 —— 跳过这一轮)。 */
461
+ suggestSeq: 0, suggestAbort: null, suggestHistory: false, qaHadAsk: false,
462
+ /* 会话托管(2026-10-23 用户:「托管模式下自动按照最佳建议提交到会话中执行」):
463
+ 开着时每个成功收尾的轮次会把第一条(模型排序里的最佳)建议**自动发出去**。
464
+ hostingRounds = 本次开启以来连续自动发了几轮(上限见 hostingMax,防跑飞);
465
+ hostingTimer = 等待「让用户看见建议」的那一小段延时(可被任何用户动作取消)。
466
+ ⚠️ 托管开关的**权威值在宿主**(移动端「我的」tab 落 localStorage),
467
+ 组件只执行;自动暂停时通过 onHostingChange 通知宿主把开关也关掉。 */
468
+ hosting: !!opts.hosting, hostingRounds: 0, hostingTimer: 0,
469
+ /* 提问卡的托管(2026-10-23 用户:「托管碰到提问卡也一样发最佳建议继续」):
470
+ 卡一出现就等 hostingDelay,然后自动选第一项(优先「继续/下一步」措辞)交卷;
471
+ 用户点卡上任何地方 / 关掉卡 / 自己作答 → 取消这次自动答,不抢人的操作。 */
472
+ hostingAskTimer: 0, hostingAskCard: null, hostingAskAnswered: false,
436
473
  /* 最近一条**已画到画布上**的提问(签名 = 正文)。同一条提问会从三处再走一遍:
437
474
  发送时的本地回声 / 历史日志回放 / 网关在 ask 缓冲头合成的 `type:"user"`(seq 固定 0)。
438
475
  没有这道签名,一次提问可能画出两三个一模一样的气泡(2026-10-20 用户报「出来三个」)。 */
439
476
  lastUserSig: ''
440
477
  };
441
478
 
479
+ /* 托管的两条护栏(都可被宿主覆盖,主要给测试用):
480
+ · hostingDelay —— 建议画出来后等一小会儿再自动发,让用户看见「要发哪句」并有机会暂停;
481
+ · hostingMax —— 连续自动执行的上限。没有这条,一个「继续」接「继续」的循环能烧一整夜;
482
+ 到顶就暂停并通知宿主,不静默停。 */
483
+ var hostingDelay = 1200, hostingMax = 12;
484
+ if (opts && typeof opts.hostingDelay === 'number' && isFinite(opts.hostingDelay)) {
485
+ hostingDelay = Math.max(0, Math.min(5000, opts.hostingDelay));
486
+ }
487
+ if (opts && typeof opts.hostingMax === 'number' && isFinite(opts.hostingMax)) {
488
+ hostingMax = Math.max(1, Math.min(50, Math.floor(opts.hostingMax)));
489
+ }
490
+
442
491
  /* ══════════ markdown 渲染(**栈归内核**)══════════
443
492
  2026-10-16 阶段三:本文件以前自带一套手写解析(inlineMd / readTable / readList /
444
493
  codePanelHtml / mdToHtml,约 200 行)—— 只覆盖「agent 回复里实际会出现」的子集,
@@ -626,6 +675,9 @@
626
675
  「一条事件流 → 对话」的映射收归对话内核一份(`shared/conv/09-stream-apply.js`),
627
676
  组件这边只递事件、递画布、把回调接回自己的状态。见下面 kernelApply。 */
628
677
  kernelApply(ev);
678
+ /* 提问卡一出现就排托管自动答(卡是 kernelApply 里同步挂到停靠位的)。
679
+ 每个事件都问一次是廉价的:没有未答的卡就什么都不做,有卡且已有计时器就直接返回。 */
680
+ if (state.hosting && !state.replay) scheduleHostingAsk();
629
681
  }
630
682
 
631
683
  /* ══════════ 事件 → 画面:走内核那一份实现 ══════════
@@ -690,7 +742,10 @@
690
742
  function turnHasContent(h) {
691
743
  var el = h && h.el;
692
744
  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');
745
+ /* ⚠️ `.qa-mk` = 标记行(notice / system / turn_failed 都画成它)。漏掉它的话,
746
+ 一轮只有过程提示(团队模式的心跳、计划播报)而没有正文时,整条消息会被
747
+ dropEmptyAnswer 当「空块」撤掉 —— 历史回放里那些提示就凭空消失了。 */
748
+ 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
749
  }
695
750
  /** 只有「引擎抬头」的助手块就撤掉(否则收尾行上方留一块空白,2026-10-20 用户截图)。 */
696
751
  function dropEmptyAnswer(h) {
@@ -717,7 +772,7 @@
717
772
  if (e.model) state.model = String(e.model);
718
773
  syncPickers();
719
774
  }
720
- if (t === 'ask') state.awaitingAnswer = true; // 引擎在等这一问的答案(见 MagicHost.send)
775
+ if (t === 'ask') { state.awaitingAnswer = true; state.qaHadAsk = true; } // 引擎在等这一问的答案(见 MagicHost.send)
721
776
  /* 画布上已经有东西了 → 撤掉那句空态说明(它只在一条都还没画时该在) */
722
777
  var em = document.getElementById('qa-stream');
723
778
  var empty = em && em.querySelector('.conv__empty');
@@ -843,10 +898,389 @@
843
898
  onDone: function (e) { kernelDone(true, (e && e.stopped) ? '已停止' : '已完成'); },
844
899
  };
845
900
  }
901
+ /* ══════════ 下一轮建议(2026-10-23)══════════
902
+ 用户:「在回复结束后 ai 自动生成下一轮的建议,点击即可发送」。
903
+ 生成在**桌面端**做(core 的 --suggest-followups,见 internal/suggest):提示词与解析口径
904
+ 只有一份,手机只拿结果。这一层只负责「什么时候问、画在哪、点了怎么发」。
905
+
906
+ · 只在**成功收尾**且没有提问卡的那一轮问(失败 / 被停 / 历史回放都不问:
907
+ 那些轮没有「下一步」可言,问了也只会拿到过期的建议);
908
+ · 一次只有一个在途请求:开新轮 / 换会话 / 销毁立刻 abort(core 侧随之杀进程),
909
+ 响应回来时用 suggestSeq 对号,对不上就丢 —— 否则上一轮的建议会画到下一轮上;
910
+ · 点击 = **直接发送**(走 MagicHost.send,与用户打字同一条路径:内核画用户气泡、
911
+ 忙时自动排队、附件闸门一致)。不做「填进输入框」两极分化 —— 用户说的是点击即发;
912
+ · 拿不到建议(接口 4xx/5xx、超时、模型给了空数组)就**什么都不画**,不拿模板凑。 */
913
+
914
+ /* 建议行属于**哪一次建议**:{ sid, seq }。
915
+ 2026-10-23(用户:「排队的和自动建议发送串会话了」):这之前建议行只按「DOM 里还有没有
916
+ 这枚按钮」存在,托管那条路径(scheduleHostingSend)的延时窗口里也只复核了 busy /
917
+ 输入框 / 附件 —— 没有一条核对「还是不是原来那条会话、还是不是同一批建议」。
918
+ 于是切会话后那 1.2s 一到,上一条会话的建议就发进了新会话(画布、session 续接一起串)。
919
+ 现在每次请求 / 渲染都把身份记在这里,自动发送前逐条核对。 */
920
+ var sugId = { sid: '', seq: -1 };
921
+
922
+ /** 清空建议行 + 断掉在途请求(序号 +1 让迟到的响应失效)。 */
923
+ function clearSuggest() {
924
+ clearHostingTimer(); // 建议行都没了,等待中的自动发送也必须作废
925
+ clearHostingAsk(); // 提问卡的自动作答同理
926
+ state.suggestSeq++; // 迟到响应的作废标记(见 requestSuggestions)
927
+ state.suggestHistory = false;
928
+ sugId = { sid: '', seq: -1 }; // 建议行都没了:托管那条也不再有任何可发的身份
929
+ var ac = state.suggestAbort;
930
+ state.suggestAbort = null;
931
+ if (ac) { try { ac.abort(); } catch (e) {} }
932
+ if (ctx.sugBar) { ctx.sugBar.innerHTML = ''; ctx.sugBar.hidden = true; }
933
+ }
934
+
935
+ /* 历史会话的“最后一轮建议”(2026-10-23 用户:「任何历史会话现在点进去都看不到建议」):
936
+ 只在打开会话、回放完最后一轮之后请求一次;结果按 (sid, 最后一条 user 的 at/seq)
937
+ 缓存在 localStorage,30 分钟内重开不重复调模型。历史建议**只展示**,绝不交给托管
938
+ 自动发 —— 否则一打开旧会话,托管会立刻把几天前的一句建议发进去。 */
939
+ var HISTORY_SUGGEST_TTL = 30 * 60 * 1000;
940
+
941
+ function historySuggestKey(sid, u) {
942
+ if (!sid || !u) return '';
943
+ var at = String(u.at || ''), seq = String(u.seq == null ? '' : u.seq);
944
+ /* `at` 是这一轮的唯一锚:老日志没有 at 时宁可不缓存,也不拿每轮都从 1 开始的
945
+ seq 当键 —— 那会把上一轮的旧建议画到新的一轮上。 */
946
+ if (!at) return '';
947
+ return 'magic.suggest.v1.' + sid + '.' + encodeURIComponent(at) + '.' + seq;
948
+ }
949
+
950
+ function readHistorySuggest(key) {
951
+ if (!key) return null;
952
+ try {
953
+ var raw = window.localStorage.getItem(key);
954
+ if (!raw) return null;
955
+ var o = JSON.parse(raw);
956
+ if (!o || !Array.isArray(o.items) || !o.at) return null;
957
+ if (Date.now() - Number(o.at) > HISTORY_SUGGEST_TTL) {
958
+ window.localStorage.removeItem(key);
959
+ return null;
960
+ }
961
+ return o.items.filter(function (x) { return typeof x === 'string' && x.trim(); });
962
+ } catch (e) { return null; }
963
+ }
964
+
965
+ function writeHistorySuggest(key, items) {
966
+ if (!key) return;
967
+ try {
968
+ window.localStorage.setItem(key, JSON.stringify({ at: Date.now(), items: items || [] }));
969
+ } catch (e) { /* 隐私模式 / 配额满:不缓存,下次重算,不打扰用户 */ }
970
+ }
971
+
972
+ /** 收尾后要一次建议。失败静默(控制台留一行,便于排障)。
973
+ * o.history = 打开历史会话时补的“最后一轮”建议;只展示,不排托管计时器。 */
974
+ function requestSuggestions(userText, replyText, o) {
975
+ var history = !!(o && o.history);
976
+ var cacheKey = (o && o.cacheKey) || '';
977
+ clearSuggest();
978
+ if (state.replay) return;
979
+ /* 提问卡出现过的轮次:以前一律不请求(卡还在等人答,建议会抢注意力)。
980
+ 托管开着且这一问**已经由托管自动答掉**时例外 —— 用户要的就是答完继续,
981
+ 下一轮的正常建议必须照常生成。手动答的卡由 fireTurn 把 qaHadAsk 清掉,不会走到这里。 */
982
+ if (state.qaHadAsk && !(state.hosting && state.hostingAskAnswered)) return;
983
+ /* 宿主没接这条通道(老驱动 / 测试桩)→ 如实不做,不假装有建议。 */
984
+ if (!transport || typeof transport.suggest !== 'function') return;
985
+ var reply = String(replyText == null ? '' : replyText).trim();
986
+ if (reply.length < 12) return; // 一句「好的 / 已完成」没有下一步可提炼,省一次模型调用
987
+ var sid = state.session || '';
988
+ var seq = state.suggestSeq;
989
+ state.suggestHistory = history;
990
+ var ac = null;
991
+ try { ac = new AbortController(); } catch (e) { ac = null; }
992
+ state.suggestAbort = ac;
993
+ /* 界面侧 20s 放弃:core 侧是 22s / 插件 25s,留出余量让服务端先把进程收掉。 */
994
+ var timer = setTimeout(function () { if (ac) { try { ac.abort(); } catch (e) {} } }, 20000);
995
+ Promise.resolve(transport.suggest({
996
+ user: String(userText == null ? '' : userText).slice(0, 4000),
997
+ 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: ''
1004
+ }, { signal: ac ? ac.signal : undefined }))
1005
+ .then(function (res) {
1006
+ if (seq !== state.suggestSeq) return; // 已经开新轮 / 换会话 / 又收尾了
1007
+ if (state.replay || state.busy) return;
1008
+ if ((state.session || '') !== sid) return;
1009
+ /* 身份记在**渲染之前**:renderSuggestions 里会把托管计时器排上,它要读这个身份。 */
1010
+ sugId = { sid: sid, seq: seq };
1011
+ state.suggestHistory = history;
1012
+ var items = (res && Array.isArray(res.suggestions)) ? res.suggestions : [];
1013
+ renderSuggestions(items, history);
1014
+ if (cacheKey) writeHistorySuggest(cacheKey, items);
1015
+ })
1016
+ .catch(function (e) {
1017
+ if (seq !== state.suggestSeq) return;
1018
+ /* 超时 / abort 是常态(用户已经发了下一句),不打扰;其它失败也只在控制台说一声。 */
1019
+ if (e && e.name === 'AbortError') return;
1020
+ console.info('[conv] 建议生成失败:' + ((e && e.message) || e));
1021
+ })
1022
+ .then(function () {
1023
+ clearTimeout(timer);
1024
+ if (state.suggestAbort === ac) state.suggestAbort = null;
1025
+ });
1026
+ }
1027
+
1028
+ /** 画建议行(最多 3 枚;空数组 = 收起,一个像素都不占)。 */
1029
+ function renderSuggestions(list, history) {
1030
+ var bar = ctx.sugBar; if (!bar) return;
1031
+ var hist = !!history;
1032
+ var items = (Array.isArray(list) ? list : [])
1033
+ .filter(function (s) { return typeof s === 'string' && s.trim(); })
1034
+ .slice(0, 3);
1035
+ state.suggestHistory = hist;
1036
+ bar.classList.toggle('sugbar--history', hist);
1037
+ bar.innerHTML = '';
1038
+ if (!items.length) { bar.hidden = true; return; }
1039
+ /* 托管态先落一枚「托管中」标记:用户一眼看到「接下来会自动发」,
1040
+ 点它当场暂停(与「我的」里的开关是同一个状态,只差一个更近的入口)。 */
1041
+ if (state.hosting && !hist) {
1042
+ var tag = el('button', 'sugtag');
1043
+ tag.type = 'button';
1044
+ tag.textContent = '托管中';
1045
+ tag.title = '会话托管:' + Math.round(hostingDelay / 100) / 10 + ' 秒后自动发送第一条建议;点击暂停';
1046
+ tag.addEventListener('click', function () { pauseHosting(''); });
1047
+ bar.appendChild(tag);
1048
+ }
1049
+ items.forEach(function (text, i) {
1050
+ var b = el('button', 'sugchip');
1051
+ b.type = 'button';
1052
+ b.textContent = text;
1053
+ b.title = text;
1054
+ if (state.hosting && !hist && i === 0) b.classList.add('sugchip--next');
1055
+ b.addEventListener('click', function () { pickSuggestion(text); });
1056
+ bar.appendChild(b);
1057
+ });
1058
+ bar.hidden = false;
1059
+ if (state.hosting && !hist) scheduleHostingSend(items[0]);
1060
+ }
1061
+
1062
+ /** 点了建议 = 用户明确要发这句:清掉建议行,走与打字完全相同的发送路径。 */
1063
+ function pickSuggestion(text) {
1064
+ var t = String(text == null ? '' : text).trim();
1065
+ if (!t) return;
1066
+ state.hostingRounds = 0; // 用户亲手点的:托管计数重新起算
1067
+ clearSuggest();
1068
+ if (window.MagicHost && typeof window.MagicHost.send === 'function') window.MagicHost.send(t);
1069
+ }
1070
+
1071
+ /* ══════════ 会话托管(2026-10-23)══════════
1072
+ 用户:「托管模式下自动按照最佳建议提交到会话中执行,是否开启在我的 tab 中可配置」。
1073
+
1074
+ 这里只做**执行**,开关的权威值在宿主(移动端「我的」→ 会话托管)。
1075
+ 一次托管轮次的完整路径:
1076
+ 成功收尾 → 生成建议 → 画出来(含「托管中」标记)→ 等 hostingDelay
1077
+ → 复核「还该发吗」→ 第一条建议直接走 fireTurn(用户气泡、beforeAsk、权限、排队全部照旧)。
1078
+
1079
+ 复核不是形式:延时期间用户可能已经开始打字、贴了附件、点了别的建议、
1080
+ 切了会话、或者干脆把托管关了 —— 任何一条命中都必须放弃这次自动发送。
1081
+ 自动暂停(失败 / 被停 / 到上限 / 用户暂停)都回调 onHostingChange,
1082
+ 让宿主把「我的」里那枚开关同步关掉,不留下「界面显示开着、其实已经不跑了」的错觉。 */
1083
+
1084
+ function clearHostingTimer() {
1085
+ if (state.hostingTimer) { clearTimeout(state.hostingTimer); state.hostingTimer = 0; }
1086
+ }
1087
+
1088
+ /** 撤掉提问卡的自动作答:清计时器 + 把卡头那枚「托管中」标签摘掉。
1089
+ * 不重置 hostingAskAnswered —— kernelDone 还要靠它决定「这一问是托管答的,可以继续出建议」。 */
1090
+ function clearHostingAsk() {
1091
+ if (state.hostingAskTimer) { clearTimeout(state.hostingAskTimer); state.hostingAskTimer = 0; }
1092
+ var card = state.hostingAskCard;
1093
+ state.hostingAskCard = null;
1094
+ if (card) {
1095
+ var tag = card.querySelector('.qa-ask-hosting');
1096
+ if (tag && tag.parentNode) tag.parentNode.removeChild(tag);
1097
+ }
1098
+ }
1099
+
1100
+ /** 「最佳」= 优先挑措辞像继续的选项;没有就第一项(模型给的顺序本来就是推荐序)。 */
1101
+ function bestAskOption(qb) {
1102
+ var opts = [].slice.call(qb.querySelectorAll('.qa-ask-opt'));
1103
+ if (!opts.length) return null;
1104
+ var good = /继续|下一步|开始|执行|确认|是|好/;
1105
+ var bad = /取消|停止|拒绝|不用|否|关闭/;
1106
+ for (var i = 0; i < opts.length; i++) {
1107
+ var lb = String(opts[i].dataset.label || opts[i].textContent || '');
1108
+ if (good.test(lb) && !bad.test(lb)) return opts[i];
1109
+ }
1110
+ return opts[0];
1111
+ }
1112
+
1113
+ /** 真正的交卷:把每一问的第一项点中,再让内核的 qaAskMark 落定。
1114
+ * 全单选时点最后一项内核会自己发;带多选时它要等 1.5s,这里 80ms 后直接收笔。 */
1115
+ function fireHostingAsk() {
1116
+ state.hostingAskTimer = 0;
1117
+ var card = state.hostingAskCard;
1118
+ if (!state.hosting || !card || !card.isConnected || card.dataset.answered === '1' || card.dataset.hostingSkip === '1') {
1119
+ clearHostingAsk();
1120
+ return;
1121
+ }
1122
+ if (state.hostingRounds >= hostingMax) {
1123
+ pauseHosting('已连续自动执行 ' + hostingMax + ' 轮,先停下来确认结果');
1124
+ return;
1125
+ }
1126
+ var qbs = [].slice.call(card.querySelectorAll('.qa-ask-qb'));
1127
+ if (!qbs.length) { clearHostingAsk(); return; }
1128
+ var picks = [];
1129
+ for (var i = 0; i < qbs.length; i++) {
1130
+ var opt = bestAskOption(qbs[i]);
1131
+ if (!opt) { clearHostingAsk(); return; } // 有一问没有选项 → 不替用户猜,留给人工
1132
+ picks.push(opt);
1133
+ }
1134
+ state.hostingRounds++;
1135
+ state.hostingAskAnswered = true;
1136
+ picks.forEach(function (opt) { try { opt.click(); } catch (e) {} });
1137
+ setTimeout(function () {
1138
+ if (state.hostingAskCard === card && card.isConnected && card.dataset.answered !== '1'
1139
+ && typeof qaAskMark === 'function' && typeof qaAskText === 'function') {
1140
+ try { qaAskMark(card, qaAskText(card)); } catch (e) {}
1141
+ }
1142
+ if (state.hostingAskCard === card) clearHostingAsk();
1143
+ }, 80);
1144
+ }
1145
+
1146
+ /** 卡一出现就排一次自动答;只在**当前会话的停靠位**里找,后台会话的卡不动。 */
1147
+ function scheduleHostingAsk() {
1148
+ if (!state.hosting || state.replay || state.hostingAskTimer || state.hostingAskCard) return;
1149
+ var dock = root.querySelector('#home-askdock');
1150
+ if (!dock || dock.hidden) return;
1151
+ var cards = dock.querySelectorAll('.qa-ask');
1152
+ var card = null;
1153
+ for (var i = cards.length - 1; i >= 0; i--) {
1154
+ var c = cards[i];
1155
+ if (c.dataset.answered === '1' || c.dataset.hostingSkip === '1' || !c.isConnected) continue;
1156
+ card = c; break;
1157
+ }
1158
+ if (!card) return;
1159
+ state.hostingAskAnswered = false; // 新的一张卡:还没自动答过
1160
+ if (state.hostingRounds >= hostingMax) {
1161
+ pauseHosting('已连续自动执行 ' + hostingMax + ' 轮,先停下来确认结果');
1162
+ return;
1163
+ }
1164
+ state.hostingAskCard = card;
1165
+ var hd = card.querySelector('.qa-ask-hd');
1166
+ if (hd) {
1167
+ var tag = document.createElement('button');
1168
+ tag.type = 'button';
1169
+ tag.className = 'sugtag qa-ask-hosting';
1170
+ tag.textContent = '托管中';
1171
+ tag.title = '会话托管:' + (Math.round(hostingDelay / 100) / 10) + ' 秒后自动选第一项继续;点击暂停';
1172
+ tag.addEventListener('click', function (e) {
1173
+ e.preventDefault(); e.stopPropagation();
1174
+ pauseHosting('');
1175
+ });
1176
+ hd.insertBefore(tag, hd.querySelector('.qa-ask-x') || null);
1177
+ }
1178
+ /* 用户只要碰了卡(选项 / 其他输入 / 关闭)就取消自动答,不跟人抢。 */
1179
+ card.addEventListener('pointerdown', function (e) {
1180
+ if (e.target && e.target.closest && e.target.closest('.qa-ask-hosting')) return;
1181
+ card.dataset.hostingSkip = '1';
1182
+ clearHostingAsk();
1183
+ }, { once: true });
1184
+ card.addEventListener('focusin', function () {
1185
+ card.dataset.hostingSkip = '1';
1186
+ clearHostingAsk();
1187
+ }, { once: true });
1188
+ state.hostingAskTimer = setTimeout(fireHostingAsk, hostingDelay);
1189
+ }
1190
+
1191
+ /** 暂停托管(组件内部发起):改状态 + 通知宿主 + 留下原因。 */
1192
+ function pauseHosting(reason) {
1193
+ clearHostingTimer();
1194
+ clearHostingAsk();
1195
+ state.hostingAskAnswered = false;
1196
+ var was = state.hosting;
1197
+ state.hosting = false;
1198
+ state.hostingRounds = 0;
1199
+ if (!was) return;
1200
+ if (typeof opts.onHostingChange === 'function') {
1201
+ try { opts.onHostingChange(false, String(reason || '')); } catch (e) {}
1202
+ }
1203
+ }
1204
+
1205
+ /** 宿主设置托管开关(用户点「我的」里的开关,或新建会话时同步初值)。
1206
+ * ⚠️ 刻意不回调 onHostingChange:那是「组件自己暂停了」的回传口,
1207
+ * 宿主自己改完再收到自己的回调只会绕圈(与 setTeam 同一约定)。 */
1208
+ function setHosting(on) {
1209
+ var next = !!on;
1210
+ if (next === state.hosting) return;
1211
+ state.hosting = next;
1212
+ state.hostingRounds = 0;
1213
+ state.hostingAskAnswered = false;
1214
+ clearHostingTimer();
1215
+ clearHostingAsk();
1216
+ }
1217
+
1218
+ /** 自动发送前的复核 + 延时。text = 第一条建议(模型排序即优先级)。
1219
+ * 2026-10-23(用户:「排队的和自动建议发送串会话了」):这一条**只服务当前这次建议**。
1220
+ * 排计时器时把 sugId(会话 + 建议序号)一起拷进闭包,到点后逐条核对 ——
1221
+ * 会话换了 / 建议换代了 / 建议行已经没了 → **放弃这次自动发送**,绝不替别的会话开一轮。 */
1222
+ function scheduleHostingSend(text) {
1223
+ clearHostingTimer();
1224
+ if (!state.hosting || !text || state.suggestHistory) return;
1225
+ if (state.busy || state.queue.length) return; // 已经有活要干:不插队
1226
+ var typed = '';
1227
+ try { typed = String(ctx.input.value || '').trim(); } catch (e) { typed = ''; }
1228
+ if (typed) { pauseHosting('输入框里有没发出去的内容'); return; }
1229
+ if ((state.attachments || []).length) { pauseHosting('输入框里还有附件'); return; }
1230
+ if (state.plugin) { pauseHosting('输入框里还选着插件'); return; }
1231
+ if (state.hostingRounds >= hostingMax) {
1232
+ pauseHosting('已连续自动执行 ' + hostingMax + ' 轮,先停下来确认结果');
1233
+ return;
1234
+ }
1235
+ var ident = { sid: sugId.sid, seq: sugId.seq };
1236
+ state.hostingTimer = setTimeout(function () {
1237
+ state.hostingTimer = 0;
1238
+ if (!state.hosting) return;
1239
+ /* ① 归属不复核就等于跨会话发送:建议是**某条会话**那一轮的下一步,
1240
+ 不是「这个组件此刻挂着的那条」的。会话变了(或建议已换代)→ 直接放弃。 */
1241
+ if (ident.sid !== sugId.sid || ident.seq !== sugId.seq) return;
1242
+ if (ident.sid && (state.session || '') !== ident.sid) return;
1243
+ /* 延时期间世界可能变了:逐条复核,任何一条命中都放弃本次发送。 */
1244
+ if (state.busy || state.queue.length) return;
1245
+ var nowTyped = '';
1246
+ try { nowTyped = String(ctx.input.value || '').trim(); } catch (e) { nowTyped = ''; }
1247
+ if (nowTyped) { pauseHosting('输入框里有没发出去的内容'); return; }
1248
+ if ((state.attachments || []).length) { pauseHosting('输入框里还有附件'); return; }
1249
+ if (state.plugin) { pauseHosting('输入框里还选着插件'); return; }
1250
+ if (state.hostingRounds >= hostingMax) {
1251
+ pauseHosting('已连续自动执行 ' + hostingMax + ' 轮,先停下来确认结果');
1252
+ return;
1253
+ }
1254
+ /* 只发「此刻真的画在建议行里」的第一条:如果空建议 / 已被新响应替换 /
1255
+ 行被藏起来,宁可这次不发,也不能拿一个用户看不见的旧句子自动开一轮。
1256
+ (2026-10-23 用户报「明明没有建议,托管却发出又写一篇」的兜底闸门。) */
1257
+ var sugbar = ctx.sugBar;
1258
+ if (!sugbar || sugbar.hidden) return;
1259
+ var live = sugbar.querySelectorAll('.sugchip');
1260
+ if (!live.length || String(live[0].textContent || '').trim() !== String(text).trim()) return;
1261
+ state.hostingRounds++;
1262
+ clearSuggest(); // 建议行收起(含本条的「托管中」标记)
1263
+ /* 直接走 fireTurn:不经过输入框,不把这句话闪进 textarea;用户气泡、串行门、
1264
+ 权限与排队语义仍由同一条路负责(与点建议、打字发送完全一致)。 */
1265
+ Promise.resolve(fireTurn(String(text), [], [])).catch(function (e) {
1266
+ pauseHosting('自动发送失败:' + ((e && e.message) || e));
1267
+ });
1268
+ }, hostingDelay);
1269
+ }
1270
+
846
1271
  /** 一轮收尾(成功 / 失败 / 被停):内核的收尾动作已经做完(收思考区 / 工具卡 / 运行胶囊),
847
1272
  * 这一层只补**收尾行**(状态语 + 总耗时,取墙上时钟 —— 与掌天瓶那边同一口径)。 */
848
1273
  function kernelDone(ok, label) {
1274
+ /* 失败 / 被用户停:托管立刻暂停。这两类收尾没有「下一步」可自动执行,
1275
+ 继续挂着只会在下一轮突然又动起来 —— 用户很难把因果对上。 */
1276
+ /* ⚠️ 回放(看历史)里的失败 / 中止不算:那可能是几天前的一条旧轮次,
1277
+ 翻到它就把托管关掉,用户会以为托管自己坏了。只看**这一次真的在跑**的轮次。 */
1278
+ if (!state.replay && (!ok || label === '已停止')) {
1279
+ pauseHosting(!ok ? '这一轮失败了' : '这一轮被停止');
1280
+ }
849
1281
  var q = state.qa; if (!q) return; // 幂等:终态可能来两条(turn_end 之后还有 end)
1282
+ /* 上一轮的建议随这一轮结束一起作废;新的那批在下面按结果决定要不要求。 */
1283
+ clearSuggest();
850
1284
  state.qa = null;
851
1285
  state.qaClosed = true; // 迟到事件不再开新一笔(见 state.qaClosed)
852
1286
  state.awaitingAnswer = false;
@@ -862,6 +1296,9 @@
862
1296
  不在这条消息块里,所以撤掉不丢收尾行。 */
863
1297
  dropEmptyAnswer(q.h);
864
1298
  qaRenderDone(ok, label, thread(), ms);
1299
+ /* 只有「真的跑完了」才有下一步可取:失败 / 被停 / 回放的轮次都不问。
1300
+ 请求异步进行,界面不等待 —— 结果回来再画(见 requestSuggestions)。 */
1301
+ if (ok && label !== '已停止' && !state.replay) requestSuggestions(state.lastPrompt, state.agentRaw);
865
1302
  if (opts.onBusy) { try { opts.onBusy(false); } catch (err) {} }
866
1303
  /* 收掉运行态(2026-10-20 用户:「进行中已经变了 结束的时候图没变」)。
867
1304
  这里是「这一轮真的结束了」的**唯一收口**(turn_end / error / end 都落到这里),
@@ -887,6 +1324,7 @@
887
1324
  }
888
1325
 
889
1326
  function clearThread() {
1327
+ clearSuggest(); // 换会话 / 清屏:上一轮的建议不能留给下一段画面
890
1328
  histSid = null; // 画布清空 = 历史不在场了(见 setSession 的短路)
891
1329
  /* 换会话先停掉上一轮的断线续传轮询:否则补拉回来的事件会落进新会话的画面。 */
892
1330
  if (state.resumeStop) { var rs = state.resumeStop; state.resumeStop = null; try { rs(); } catch (e) {} }
@@ -916,6 +1354,9 @@
916
1354
  state.replayT0 = 0; state.replayLastAt = 0;
917
1355
  state.resumeT0 = 0;
918
1356
  state.awaitingAnswer = false;
1357
+ state.qaHadAsk = false;
1358
+ clearHostingAsk();
1359
+ state.hostingAskAnswered = false;
919
1360
  clearWorking(); // 递带停掉秒表(只置 null 会让定时器空转)
920
1361
  state.autoFollow = true;
921
1362
  if (mdTimer) { clearTimeout(mdTimer); mdTimer = 0; }
@@ -1006,6 +1447,10 @@
1006
1447
  }
1007
1448
 
1008
1449
  async function send() {
1450
+ /* 用户亲手发了一句:托管计数重新起算(手动接管意味着「我还想继续」);
1451
+ 正在等的那次自动发送也立刻作废 —— 用户已经自己发了,不能再补一条。 */
1452
+ state.hostingRounds = 0;
1453
+ clearHostingTimer();
1009
1454
  /* 插件胶囊在场时,用户写的只是「做什么」;**真正下发的提示词**由这一层补上
1010
1455
  「用哪个 CLI 做」(见 buildPluginPrompt)。所以这里读的是原文,
1011
1456
  两个都空才拦(只有插件、没写要求时用插件的模板兜住)。 */
@@ -1042,21 +1487,18 @@
1042
1487
  /* `state.queue.length` 也算「忙」:flush 已经在路上的那一小段窗口里,
1043
1488
  新提交的必须排到队尾,不能插到前一条排队追问的前面。 */
1044
1489
  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 };
1490
+ /* 2026-10-22 用户定稿:「排队的列表应该显示在对话框上面不进对话区域」,
1491
+ 同日再收一句「排队的任务列表是挂在对话框外面不是里面」。
1492
+ 排队的那条**还没跑**,画成用户气泡会让人以为已经发出去了;它只进输入框
1493
+ **外面**上沿的 queuebar(见 renderQueue),轮到它时再由 fireTurn 正常落进画布。
1494
+ (前一天曾在画布上先画气泡,用户已明确改回这个口径。) */
1495
+ var item = { id: qid(), text: text, atts: sentAtts, attPaths: attPaths, bubble: null,
1496
+ /* 2026-10-23:条目**当场钉死归属会话**(提交这一刻的 state.session)。
1497
+ 新会话的首句排进来的那一刻 state.session 还是空串,等 core 回 session_id
1498
+ 才填上 —— 落库时再读就成了空串,接回时按空串匹配等于「谁都可能是它」。
1499
+ 见 notifyQueue / flushQueue 里按它做的核对。 */
1500
+ sid: state.session || '' };
1054
1501
  state.queue.push(item);
1055
- /* 取消入口做在**气泡上**(排队条那边不再重复列一遍,见 renderQueue):
1056
- 用户要撤的就是这一句话,按钮贴在它身上最直接。 */
1057
- if (qb) item.unmountCancel = mountQueueCancel(qb, item);
1058
- /* 气泡已经画过了,轮到它时别再画一遍。 */
1059
- state.noEcho = false;
1060
1502
  renderQueue();
1061
1503
  /* 这条队列只在内存里,刷新/关 App 就整条消失(2026-10-21 用户:
1062
1504
  「非团队的串行排队也没落库」)。交给宿主记进它那套持久化队列
@@ -1087,6 +1529,10 @@
1087
1529
  preBubble:这条气泡**已经画过**(排队时当场画的,见 send 的排队分支)——
1088
1530
  传进来的话就只把它从「等待中」恢复成正常态,不再重画。 */
1089
1531
  async function fireTurn(text, sentAtts, attPaths, preBubble) {
1532
+ /* 新的一轮开始 = 上一轮的建议作废(它们描述的是上一轮的下一步)。 */
1533
+ clearSuggest();
1534
+ state.qaHadAsk = false;
1535
+ state.hostingAskAnswered = false;
1090
1536
  state.lastPrompt = text;
1091
1537
  state.lastAttPaths = attPaths || []; // 记住本轮附件,重试时一起重发
1092
1538
  /* 用户那条气泡由**内核**画(qaRenderMine)—— 与另外两端逐字同一份实现;
@@ -1132,7 +1578,11 @@
1132
1578
  (少了 session 追问会变成新会话,少了 team 会静默降级成单任务)。 */
1133
1579
  payload = {
1134
1580
  id: it.id, prompt: it.text, attachments: (it.attPaths || []).slice(),
1135
- session: state.session || '', workspace: state.workspace || '',
1581
+ /* atts = 气泡缩略图的快照({name, remoteUrl})。落库时必须一起带上:
1582
+ 宿主重新打开会话时用 adoptQueue 把排队气泡画回来,只存本地路径的话
1583
+ 恢复出来的气泡会丢图(用户看到的是一句没有截图的话)。 */
1584
+ atts: (it.atts || []).slice(),
1585
+ session: it.sid || state.session || '', workspace: state.workspace || '',
1136
1586
  engine: state.engine, model: state.model, permission: state.permission,
1137
1587
  team: state.team ? teamPayload() : null
1138
1588
  };
@@ -1140,39 +1590,40 @@
1140
1590
  try { fn(payload); } catch (e) {}
1141
1591
  }
1142
1592
 
1143
- /* 排队条:**只在气泡画不出来时**兜底。
1144
- 正常路径下排队的那句已经作为气泡落在画布上了(见 send 的排队分支),
1145
- 再在输入框上方原样列一遍就是同一句话出现两次 —— 用户还得在两处找「取消在哪」。
1146
- 所以现在的分工是:
1147
- · 气泡(首选):一句「排队中 · 上一轮结束后自动发送」+ 一个「取消排队」按钮;
1148
- · 排队条(兜底):只有 bubble 没画成(老内核没有 qaRenderMine 返回值、
1149
- 或画布当时不在)的条目才在这里列一行,保证它们仍然可见、仍然能撤。 */
1593
+ /* 排队条 = **排队条目的唯一可见处**(2026-10-22 用户:「排队的列表应该显示在
1594
+ 对话框上面不进对话区域」;同日再定稿「排队的任务列表是挂在对话框外面不是里面」)。
1595
+ 位置在输入框**外面**的上沿(composer__box 之外的 queuebar,见 DOM 那段注释),
1596
+ 每条一行文本 + ✕;轮到它时由 flushQueue 从队列取走,fireTurn 再把气泡画进画布。
1597
+ 所以同一句话**只会在一个地方出现**:排队时在上方列表,发出后才进对话区。 */
1150
1598
  function renderQueue() {
1151
1599
  var bar = ctx.queueBar;
1152
1600
  if (!bar) return;
1153
1601
  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
- });
1602
+ var list = state.queue;
1603
+ bar.hidden = !list.length;
1604
+ if (list.length) {
1605
+ bar.appendChild(el('div', 'queuebar__cap', '排队中 · 本轮结束后自动发送'));
1606
+ list.forEach(function (it, idx) {
1607
+ var row = el('div', 'queuechip');
1608
+ row.appendChild(box('queuechip__ico', 'i-clock', 13));
1609
+ var t = el('span', 'queuechip__t', it.text);
1610
+ t.title = it.text;
1611
+ row.appendChild(t);
1612
+ var x = el('button', 'queuechip__x', '✕');
1613
+ x.type = 'button';
1614
+ x.setAttribute('aria-label', '取消这条排队');
1615
+ x.onclick = function () { dropQueued(idx); };
1616
+ row.appendChild(x);
1617
+ bar.appendChild(row);
1618
+ });
1619
+ }
1620
+ /* 挂到框外后,排队条占的高度是从**画布**那边让出来的(框的尺寸不变):
1621
+ 本来贴着底看的人,条子一出现最后一行就会被挤掉半截;撤掉(收尾自动发出)时同理。
1622
+ autoFollow 时贴一次底 —— 必须在**填完内容之后**量(先量后填,撑高的那截又会被裁掉)。 */
1623
+ if (state.autoFollow) scrollBottom();
1172
1624
  }
1173
1625
 
1174
- /* 从队列里撤掉一条:气泡(画过的)与排队条(兜底项)都要清掉,
1175
- 否则画布上留着一句永远不会被回答的话。 */
1626
+ /* 从队列里撤掉一条:排队条与(历史遗留的)画布气泡都清掉,并通知宿主删掉落库记录。 */
1176
1627
  function dropQueued(idx) {
1177
1628
  if (idx < 0 || idx >= state.queue.length) return false;
1178
1629
  var gone = state.queue.splice(idx, 1)[0];
@@ -1182,39 +1633,14 @@
1182
1633
  renderQueue();
1183
1634
  return true;
1184
1635
  }
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
1636
 
1211
1637
  /* 换会话 / 新建会话:排队条目属于**上一条会话**,不能落到新会话的画布里。
1212
1638
  返回取消的条数,由调用方决定怎么如实告知用户(静默丢用户刚写的东西最难查)。 */
1213
1639
  function clearQueue() {
1214
1640
  var n = state.queue.length;
1215
1641
  if (!n) return 0;
1216
- /* 排队时气泡已经画在画布上了(见 send 的排队分支):换会话就得一并撤掉,
1217
- 否则那几句话跟着进了新会话的画布,看着像「我在这个会话里问过」。 */
1642
+ /* 排队条目只在上方 queuebar(不再画进画布);换会话时一并撤掉,
1643
+ 再通知宿主删掉对应的落库记录,否则它会跟着新会话冒出来。 */
1218
1644
  state.queue.forEach(function (it) {
1219
1645
  if (it.bubble && it.bubble.parentNode) it.bubble.parentNode.removeChild(it.bubble);
1220
1646
  notifyQueue('drop', it);
@@ -1232,11 +1658,20 @@
1232
1658
 
1233
1659
  async function flushQueue() {
1234
1660
  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');
1661
+ var it = state.queue[0];
1662
+ /* ⚠️ 2026-10-23(用户:「排队的和自动建议发送串会话了」):这条**不是当前会话排的**就整队作废,
1663
+ 一条都不发 —— 队列跟会话走,实例被复用到别的会话后,把上一条会话的追问发出去就是
1664
+ 张冠李戴(不仅气泡画错,`session` 续接也跟着串)。正常情况下 setSession/newSession
1665
+ 已经 clearQueue 过;这里是第二道闸:接回(adoptQueue)与运行时落定的 session_id
1666
+ 之间有时间差,闸门必须自己判,不能只靠调用方记得清。 */
1667
+ if (it.sid && state.session && it.sid !== state.session) {
1668
+ var n = clearQueue();
1669
+ if (n) noticeItem('排队追问属于另一条会话,已取消 ' + n + ' 条');
1670
+ return;
1671
+ }
1672
+ state.queue.shift();
1673
+ /* 轮到它了:先把它从上方排队列表里摘掉(renderQueue 重画),再由 fireTurn
1674
+ 正常画进画布 —— 同一句话不会在列表和对话区各出现一次。 */
1240
1675
  renderQueue();
1241
1676
  /* 轮到它了:宿主那条「等待中」该换成真正跑起来的会话(见 notifyQueue)。 */
1242
1677
  notifyQueue('take', it);
@@ -1247,6 +1682,43 @@
1247
1682
  if (state.queue.length) scheduleFlush();
1248
1683
  }
1249
1684
 
1685
+ /* ── 把宿主落库的排队条目接回**输入框上方的排队列表**(2026-10-22)──
1686
+ 需求演进:排队条目**不进对话区**(用户当天定稿「排队的列表应该显示在
1687
+ 对话框上面不进对话区域」);重进会话时也要在这里看到它。
1688
+ 根因:排队条目原先只有组件内存里的 state.queue。组件被销毁重建(返回列表
1689
+ 后再进来 / 切过别的会话 / WebView 被系统回收)内存队列就没了;历史回放读的是
1690
+ **已落盘**日志,这条还没发出去,日志里当然没有 —— 于是凭空消失,宿主那条
1691
+ 持久化记录还躺在 PENDING 里没人认领。
1692
+ 现在宿主在历史画完之后把属于这条会话的条目喂回来:列进 queuebar、接回队列,
1693
+ 这一轮收尾时照常 flush。id 沿用宿主的持久化 id —— 轮到 / 取消时宿主据此对账
1694
+ (onQueueTake / onQueueDrop),不会删错行。
1695
+ items: [{id, prompt, session?, attachments:[core 路径], atts:[{name,remoteUrl}]}]
1696
+ 2026-10-23:`session` = 宿主核对过归属的那条会话(adoptPendingFor 按它筛过),
1697
+ 条目把它记进自己的 sid —— flushQueue 发之前按它核对,不属于当前会话就整队作废。
1698
+ (这之前只按「宿主筛过了」信任,而宿主那一侧的匹配在 session 为空时是放行的。) */
1699
+ function adoptQueue(items) {
1700
+ if (!Array.isArray(items) || !items.length) return 0;
1701
+ var n = 0;
1702
+ items.forEach(function (it) {
1703
+ if (!it || !it.id || !it.prompt) return;
1704
+ if (state.queue.some(function (x) { return x.id === it.id; })) return;
1705
+ var atts = (it.atts || []).slice();
1706
+ var attPaths = (it.attachments || []).slice();
1707
+ /* 只入队 + 列表:不画进画布(还没发出去,画成用户气泡会让人以为已发送)。 */
1708
+ var item = { id: it.id, text: it.prompt, atts: atts, attPaths: attPaths, bubble: null,
1709
+ sid: it.session || state.session || '' };
1710
+ state.queue.push(item);
1711
+ n++;
1712
+ });
1713
+ if (n) {
1714
+ renderQueue();
1715
+ /* 这一刻组件刚回放完历史:如果那一轮已经收尾(busy=false)就立刻发,
1716
+ 还在跑(follow 已接管 busy)则由 followEnd 的 scheduleFlush 接手。 */
1717
+ scheduleFlush();
1718
+ }
1719
+ return n;
1720
+ }
1721
+
1250
1722
  async function runTurn(prompt, attPaths, bubble) {
1251
1723
  /* 本页原本在尾随别的进程跑的那一轮(follow):现在自己要发新一轮 →
1252
1724
  先把旧尾随掐掉。**不能等收尾再掐** —— 旧会话的增量会继续 render,
@@ -1369,7 +1841,13 @@
1369
1841
  if (el && !el.querySelector('.qa-t, .qa-think, .qa-tools, .qa-tool, .qa-ask, .qa-agent, .qa-mk, .qa-sep, .qa-img')) {
1370
1842
  if (el.parentNode) el.parentNode.removeChild(el);
1371
1843
  }
1372
- kernelDone(false, label || (failed ? '没有收到回复 · core 异常退出' : '没有收到回复 · 连接中断'));
1844
+ /* ⚠️ 兜底收尾也要分成功/失败(2026-10-23 实测):
1845
+ 有些引擎(如 llm)只发 result 不发 turn_end,走的就是这条 hardFinish。
1846
+ 以前无条件传 false,于是「已完成」的轮次在建议门 / 托管暂停里被当成失败:
1847
+ 建议永远不请求、托管第一轮就被暂停。判据只看**明确的成功措辞**;
1848
+ 「连接中断 / core 异常退出」一律仍算失败。 */
1849
+ var good = !failed && !!label && label.indexOf('已完成') === 0;
1850
+ kernelDone(good, label || (failed ? '没有收到回复 · core 异常退出' : '没有收到回复 · 连接中断'));
1373
1851
  } else {
1374
1852
  noticeItem('本轮没有收到任何输出(连接可能已断开),请重试');
1375
1853
  }
@@ -1851,6 +2329,54 @@
1851
2329
  return { index: idx, terminal: terminal, at: idx >= 0 ? replayAt(events[idx]) : 0 };
1852
2330
  }
1853
2331
 
2332
+ /* 历史最后一轮能不能补建议:必须是**成功收尾**、没有待答提问卡、正文够长。
2333
+ `lastTurnInfo` 只回答“有没有终态”,这里再判“终态是不是成功 + 正文在哪”。 */
2334
+ function historyTurnReply(events, idx) {
2335
+ if (idx < 0) return null;
2336
+ var end = null, result = null, failed = false, pendingAsk = false, texts = [];
2337
+ for (var i = idx + 1; i < events.length; i++) {
2338
+ var e = events[i] || {};
2339
+ var k = e.kind || e.type;
2340
+ if (k === 'ask' || k === 'permission') pendingAsk = true;
2341
+ if (k === 'turn_failed' || k === 'error') failed = true;
2342
+ if (k === 'turn_end') end = e;
2343
+ if (k === 'result') result = e;
2344
+ if (k === 'text' && e.text) texts.push(String(e.text));
2345
+ }
2346
+ if (failed || pendingAsk) return null;
2347
+ var term = end || result;
2348
+ if (!term) return null;
2349
+ var st = String(term.status || '').toLowerCase();
2350
+ if (/fail|error|stop|cancel|abort/.test(st)) return null;
2351
+ var reply = String(term.text || '').trim();
2352
+ if (reply.length < 12) reply = texts.join('').trim();
2353
+ if (reply.length < 12) return null;
2354
+ return reply;
2355
+ }
2356
+
2357
+ /* 打开历史会话时,只给**最后一轮**补一次建议:
2358
+ · 还在跑 / 刚接上实时流 → 不补(live / partial,收尾后走实时那条);
2359
+ · 最后一轮失败 / 被停 / 有提问卡 → 不补;
2360
+ · 先读缓存(按 sid + 最后一条 user 的 at/seq),有就画,没有才请求;
2361
+ · 画出来的建议**不排托管**(renderSuggestions 的 history 模式)。 */
2362
+ function maybeHistorySuggest(sid, events, idx) {
2363
+ if (!sid || idx < 0 || state.busy || state.replay || state.suggestAbort) return;
2364
+ /* 已经有实时建议画着(刚跑完那条会话)→ 不重复请求。 */
2365
+ if (ctx.sugBar && !ctx.sugBar.hidden && ctx.sugBar.querySelector('.sugchip')) return;
2366
+ var reply = historyTurnReply(events, idx);
2367
+ if (!reply) return;
2368
+ var u = events[idx] || {};
2369
+ var key = historySuggestKey(sid, u);
2370
+ if (key) {
2371
+ var cached = readHistorySuggest(key);
2372
+ if (cached) {
2373
+ if (cached.length) renderSuggestions(cached, true);
2374
+ return;
2375
+ }
2376
+ }
2377
+ requestSuggestions(String(u.text || ''), reply, { history: true, cacheKey: key });
2378
+ }
2379
+
1854
2380
  /* 补拉路径的收口:内核的统一收尾(qaRunEnd = finish + 摘「生成中」胶囊)之后
1855
2381
  再补收尾行。为什么不能只调 kernelDone:内核的 `result` 分支**不收尾**
1856
2382
  (实时流靠网关的 SSE `end` 帧收尾),而 ask 缓冲里只有 core 的 stdout 行 ——
@@ -2317,6 +2843,11 @@
2317
2843
  /* 首载结束:骨架屏撤下。一条都没画出来(空会话)→ 把空态那句还回去。
2318
2844
  ⚠️ 期间用户可能已经切走(迟到的这一份不许撤新会话的骨架屏)。 */
2319
2845
  if (after == null && sid === state.session) endHistLoading(true);
2846
+ /* 历史会话:回放完、确认真的是“已收尾且不在跑”的最后一轮 → 补一次建议。
2847
+ 只在首载做;分页拉更早 / 接续实时流都不做。 */
2848
+ if (after == null && sid === state.session && !live && !partial && info.terminal) {
2849
+ maybeHistorySuggest(sid, events, info.index);
2850
+ }
2320
2851
  return events.length;
2321
2852
  });
2322
2853
  /* 读失败也要撤骨架屏(否则它会一直挂着,看着像永远读不完)。 */
@@ -2512,17 +3043,28 @@
2512
3043
  ctx.bottomBtn = el('span', 'tobottom');
2513
3044
 
2514
3045
  var form = el('form', 'composer');
3046
+ /* 下一轮建议的停靠位(2026-10-23):收尾后由 /desk/suggest 生成、贴在这条输入框**正上方**。
3047
+ 默认藏起来 —— 没有建议时它一个像素都不占(不预留空行,也不让输入框跳位)。 */
3048
+ var sugBar = el('div', 'sugbar'); sugBar.hidden = true;
3049
+ sugBar.setAttribute('role', 'group');
3050
+ sugBar.setAttribute('aria-label', '建议追问');
3051
+ ctx.sugBar = sugBar;
3052
+ form.appendChild(sugBar);
2515
3053
  var cbox = el('div', 'composer__box');
2516
3054
  /* 提问卡的**停靠位**:内核按固定 id 找它(`home-askdock`,见 shared/conv/06-ask-answer.js),
2517
3055
  引擎提问 / 待授权时卡钉在输入框上方。显隐与进出全由内核管,这里只给一块地。 */
2518
3056
  var askDock = el('div', 'qa-askdock'); askDock.id = 'home-askdock'; askDock.hidden = true;
2519
3057
  cbox.appendChild(askDock);
2520
- /* 排队追问的停靠位(2026-10-21):运行中提交的那几条排在这里,不进画布 ——
2521
- 它们**还没跑**,画成用户气泡会让人以为已经发出去了。位置贴着输入框上沿
2522
- (与 PC 的 qaMsgList 同一意图:「新需求排列在会话框上面」)。 */
3058
+ /* 排队追问的停靠位(2026-10-21;2026-10-22 挪到框外):运行中提交的那几条排在这里,
3059
+ 不进画布 —— 它们**还没跑**,画成用户气泡会让人以为已经发出去了。
3060
+ ⚠️ 挂在 `.composer__box` **外面**(框上沿之上,见下面 form 的 append 顺序),
3061
+ 不放进框里:用户 2026-10-22 定稿「排队的任务列表是挂在对话框外面不是里面」——
3062
+ 塞进圆角框会跟正在写的正文混成一条,看着像「已经写进这一句了」。
3063
+ PC 的 `.ag-queue` 也是同一个挂法(.ag-box 之外)。 */
2523
3064
  var queueBar = el('div', 'queuebar'); queueBar.hidden = true;
3065
+ queueBar.setAttribute('role', 'group');
3066
+ queueBar.setAttribute('aria-label', '排队中');
2524
3067
  ctx.queueBar = queueBar;
2525
- cbox.appendChild(queueBar);
2526
3068
  /* 选中的插件胶囊的**停靠位**:在附件条上方、输入框之外。
2527
3069
  ⚠️ 不塞进 .attachbar 里:那是附件的地盘,混在一起后 clearAttachments
2528
3070
  (每轮必跑)会把胶囊一起清掉,而插件是「本轮怎么干」不是附件。 */
@@ -2578,6 +3120,9 @@
2578
3120
  crow.appendChild(cleft); crow.appendChild(ctx.sendBtn);
2579
3121
  cbox.appendChild(crow);
2580
3122
  form.appendChild(trow);
3123
+ /* 排队条在**框外**、紧贴框的上沿(见上面 queueBar 的注释):排在参数行之后,
3124
+ 出现时把参数行往上顶,而不是把输入框撑高 —— 框的尺寸只由输入内容决定。 */
3125
+ form.appendChild(queueBar);
2581
3126
  form.appendChild(cbox);
2582
3127
  wrap.appendChild(form);
2583
3128
  root.appendChild(wrap);
@@ -2691,6 +3236,9 @@
2691
3236
  /* 附件也算「有内容」:忙时贴了图再点发送 = 排队,不能把它当成「停止」(同 send 的闸门)。 */
2692
3237
  if (typed || state.plugin || (state.attachments || []).length) return;
2693
3238
  e.preventDefault();
3239
+ /* 用户按了「停止」:托管当场暂停,不等收尾行 —— 否则它可能在这一轮结束的
3240
+ 同一秒自动发出下一条,看起来像「点了停止还在自己跑」。 */
3241
+ pauseHosting('这一轮被停止');
2694
3242
  control('interrupt');
2695
3243
  });
2696
3244
 
@@ -2721,6 +3269,10 @@
2721
3269
  引擎那边还在等,这一问就断了。 */
2722
3270
  if (state.awaitingAnswer) { control('answer', t); return; }
2723
3271
  state.noEcho = !!(o && o.echo === false); // 内核的重试路径:那条气泡已经在画布上了
3272
+ /* ⚠️ 显式指定会话(`o.sid`)而此刻挂着的**不是它** → 一律不发。
3273
+ 调用方(内核的重试 / 追问)给出的是「这条消息属于哪条会话」;
3274
+ 组件实例被复用到别的会话后,照旧落进当前会话就是串会话。 */
3275
+ if (o && o.sid && String(o.sid) !== String(state.session || '')) return;
2724
3276
  ctx.input.value = t;
2725
3277
  send();
2726
3278
  };
@@ -3205,13 +3757,19 @@
3205
3757
  syncNewTitle();
3206
3758
  setTimeout(function () { ctx.input.focus(); }, 0);
3207
3759
  }
3208
- function setSession(sid, s) {
3760
+ function setSession(sid, s, o) {
3209
3761
  /* 同一条会话重入(移动端「返回列表 → 再点进来」的高频路径):画面就在眼前,
3210
3762
  别清屏重放整段历史 —— 宿主复用的是同一个组件实例(见 client-ui-mobile 的
3211
3763
  mountConversation),历史与实时流都还在内存里。判据除了「同一条会话」还要
3212
3764
  `histSid === sid`(历史真的在这块画布里,见 loadHistory / clearThread)。
3213
- 这里只同步外壳状态:会话标题可能在列表里被改过,引擎 / 模型要跟着清单行走。 */
3214
- if (sid && sid === state.session && histSid === sid) {
3765
+ 这里只同步外壳状态:会话标题可能在列表里被改过,引擎 / 模型要跟着清单行走。
3766
+ ⚠️ o.same:宿主**已经确认是同一屏会话**(run_id ↔ session_id 别名,组件自己
3767
+ 不知道这层映射)。此时按字面判会落到下面的「换会话」分支 ——
3768
+ clearQueue() 把排队追问整队删掉,正是「重新进来就看不到」的病根。
3769
+ 所以宿主传 same 就只同步外壳,绝不换代。 */
3770
+ var same = !!(o && o.same);
3771
+ if (sid && ((sid === state.session && histSid === sid) || same)) {
3772
+ if (sid) state.session = sid;
3215
3773
  if (s) {
3216
3774
  /* 清单行的 state / alive 是「要不要尾随实时进度」的判据(见 sessionLive):
3217
3775
  重入时它可能刚从 running 变 done,必须跟着更新,否则会去尾随一条已收尾的会话。 */
@@ -3255,10 +3813,15 @@
3255
3813
  startWatch();
3256
3814
  /* watchInflight:这次首载期间别让 watcher 再插一次 loadHistory(大日志可能超过 5s)。 */
3257
3815
  watchInflight = true;
3258
- loadHistory(sid)
3816
+ /* 返回「历史真的画完」的那个 Promise:宿主据此决定什么时候把落库的排队条目
3817
+ adoptQueue 回来(抢在回放之前插气泡,会被 clearThread 一起清掉 —— 那正是
3818
+ 「重新进来就看不到」的另一半)。复用同一屏(上面那个分支)时它已经是 0 了,
3819
+ 宿主同样不必等。 */
3820
+ return loadHistory(sid)
3259
3821
  .catch(function (e) { noticeItem('历史加载失败:' + (e.message || e)); })
3260
3822
  .then(function () { if (sid === state.session) watchInflight = false; });
3261
3823
  }
3824
+ return Promise.resolve(0);
3262
3825
  /* 打开一条已有的回话(看历史 / 接着聊)时**不**自动把焦点塞进输入框:
3263
3826
  移动端上这一下会直接顶起软键盘,刚回放出来的历史被键盘挡掉大半。
3264
3827
  要打字时用户自己点输入框即可;newSession(用户主动「新建」)仍保留聚焦。 */
@@ -3288,6 +3851,7 @@
3288
3851
  });
3289
3852
  }
3290
3853
  function destroy() {
3854
+ clearSuggest();
3291
3855
  stopWatch();
3292
3856
  Sheet.close();
3293
3857
  if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
@@ -3305,8 +3869,8 @@
3305
3869
  el: root,
3306
3870
  state: state,
3307
3871
  send: send,
3308
- interrupt: function () { return control('interrupt'); },
3309
- stop: function () { return control('stop'); },
3872
+ interrupt: function () { pauseHosting('这一轮被停止'); return control('interrupt'); },
3873
+ stop: function () { pauseHosting('这一轮被停止'); return control('stop'); },
3310
3874
  answer: function (text) { return control('answer', text); },
3311
3875
  setEngine: function (n) { state.engine = n; syncPickers(); },
3312
3876
  setModel: function (n) { state.model = n; syncPickers(); },
@@ -3325,10 +3889,14 @@
3325
3889
  openTeamPicker: openTeamPicker,
3326
3890
  featureOn: featureOn,
3327
3891
  setEngines: function (list) { state.engines = list || []; syncPickers(); },
3892
+ /* 会话托管:宿主「我的」里的开关调它;hostingOn() 给宿主做开关态回读。 */
3893
+ setHosting: setHosting,
3894
+ hostingOn: function () { return !!state.hosting; },
3328
3895
  refreshEngines: refreshEngines,
3329
3896
  setSession: setSession,
3330
3897
  newSession: newSession,
3331
3898
  loadHistory: loadHistory,
3899
+ adoptQueue: adoptQueue,
3332
3900
  render: function (ev) { render(ev); },
3333
3901
  syncPickers: syncPickers,
3334
3902
  destroy: destroy