magic-agent 1.0.3 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -0,0 +1,377 @@
1
+ /* ---------- 提问卡 → 回答(AskUserQuestion 的落笔)----------
2
+ 2026-09-18 用户:「返回了 askuserquestion,需要 ui 在对话框中增加选择显示 而不是显示这个 json」。
3
+ 卡落在对话框里,答案也要回到对话框:点选项 = 把这一问的回答作为**追问**发给同一个会话
4
+ (走 qaSend 的 followup 语义、沿用 sessionId 续接,不在左栏另开一条会话)。
5
+
6
+ ⚠️ 发送时机 —— **不打断在跑的那一轮**(用户 2026-09-18 定稿「对话中不允许上一条没结束
7
+ 直接输入下1个」;这条会话的撰写区在它自己生成中锁死):这一轮还在跑就先把答案排进
8
+ qaAskQueue,等它收尾(onDone / onError / 用户点停止)或用户切回该会话时由 qaAskFlush
9
+ 自动发出。
10
+ 空闲时(引擎早就不等回答了 —— 非交互模式下很常见)点一下立即发出,不排队。
11
+ 排队态在卡上已经是「已答」(选中项高亮 + 标题变「已回答」),不会让用户以为点了没反应。
12
+
13
+ ⚠️ 2026-09-30(用户:「确认框不应该显示在消息中 需要显示在对话框上」):卡**画在哪**改了 ——
14
+ 原来由 03-chat-message.js 的 askCard 插进**对话流**(`bd.insertBefore`),跟着消息一起滚、
15
+ 也一起进快照;现在一律挂到 `#qa-askdock`(输入框正上方那一条)。见下面「提问卡停靠位」一节。
16
+ 本文件上半部分(收答案 → 发回会话)**一个字没改**:停靠位只换画布位置,不动答题语义。 */
17
+ const qaAskQueue = new Map(); // taskId → [等着发出去的答案](一轮里可能问好几次)
18
+
19
+ /* ---------- 提问卡停靠位(2026-09-30)----------
20
+ 用户:「确认框不应该显示在消息中 需要显示在对话框上」。
21
+ 改前:AskUserQuestion 的卡是**对话流里的一张消息**(跟着滚动、跟着快照);可它其实是在
22
+ 「等用户回答」的交互件 —— 用户读到一半往下滚,卡就滑出视野了,回答入口跟着消失。
23
+ 改后:卡一律挂 `#home-askdock` —— 对话框组件(18-askbox.js 的 askBoxMarkup)里那一条,
24
+ 位置紧挨在 `.ag-box` 之上,**不进对话流、不进 `t.snapshot`**。
25
+ 为什么卡要跟着会话走:卡表达的是「**这条会话**正在等回答」,切到别的会话不该看见它 ——
26
+ 所以每张卡记自己的 taskId:当前显示的这条挂进停靠位,其余挂进**离屏缓冲**(节点不销毁,
27
+ 切回来直接搬回去,事件委托与已勾选状态都还在,见 07-chat-stream-render.js 的委托)。
28
+ ⚠️ 待回答的卡**只活在内存**里:它不进快照(快照是对话流的画面),也不进 SQLite 消息表
29
+ (提问题本来就不是一条消息)。所以应用重启后不会残留「待回答」——这正是要的效果:
30
+ 非交互模式下引擎问完就自己往下跑了,重启后再弹一张旧问题反而是幽灵。
31
+ ⚠️ 收答案的唯一出口仍是本文件上半部分的 `qaAskMark → qaAskDeliver → qaAskSend`
32
+ (点选项 = 把 `【用户选择】…` 作为**追问**发回这条会话)。停靠位不掺和这件事。 */
33
+ const qaAskDockCards = new Map(); // taskId → [card,…](按到达顺序;当前会话那批就在停靠位里)
34
+ const qaAskDockParked = new Map(); // taskId → 离屏缓冲容器(该会话不显示时,它的卡挂这儿)
35
+ const QA_ASKDOCK_ID = "home-askdock"; // = 会话框实例(QA_QUEUE_INSTANCE = "home")的 `${p}-askdock`
36
+ const qaAskDockEl = ()=> $("#" + QA_ASKDOCK_ID);
37
+ /** 这张卡属于哪条会话:由**渲染宿主**反推(可见画布 = 当前显示的这条;后台 run 的 holder =
38
+ * 那条 run 的 taskId)。取不到就落到当前会话 —— 停靠位宁可挂错一条,也不能把卡丢掉。 */
39
+ function qaAskDockTaskOf(host){
40
+ if(host && host.id !== "qa-stream" && typeof qaRuns !== "undefined"){
41
+ for(const r of qaRuns.values()) if(r.holder === host) return r.taskId || "";
42
+ }
43
+ return qaActiveId || "";
44
+ }
45
+ function qaAskDockBuffer(taskId){
46
+ let b = qaAskDockParked.get(taskId);
47
+ if(!b){ b = document.createElement("div"); b.hidden = true; qaAskDockParked.set(taskId, b); }
48
+ return b;
49
+ }
50
+ /** 搬移的**唯一**出口:当前这条的卡全部在停靠位,其余收回各自的离屏缓冲,顺手按有无卡切显隐。
51
+ * 新卡到达 / 切会话 / 删会话都走它 —— 三处各写一份搬移逻辑必然会漂移。 */
52
+ function qaAskDockSync(taskId){
53
+ const id = taskId == null ? (qaActiveId || "") : taskId;
54
+ const dock = qaAskDockEl();
55
+ qaAskDockCards.forEach((list, tid)=>{
56
+ const host = (dock && tid === id) ? dock : qaAskDockBuffer(tid);
57
+ list.forEach(c=>{ if(c.parentNode !== host) host.appendChild(c); });
58
+ });
59
+ if(dock) dock.hidden = !((qaAskDockCards.get(id) || []).length);
60
+ }
61
+ /** 新卡落位(askCard 调)。 */
62
+ function qaAskDockAdd(card, taskId){
63
+ if(!card) return;
64
+ const id = taskId == null ? (qaActiveId || "") : taskId;
65
+ const list = qaAskDockCards.get(id) || [];
66
+ if(!list.includes(card)) list.push(card);
67
+ qaAskDockCards.set(id, list);
68
+ qaAskDockSync(id);
69
+ }
70
+ /** 撤下一张卡(答完自动撤 / 用户点 × 主动撤):摘 DOM + 出登记表,空了就清掉这条会话的座位。 */
71
+ function qaAskDockDrop(card){
72
+ if(!card) return;
73
+ qaAskAutoCancel(card); // 卡都要撤了 → 挂在这张卡上的「答满即走」计时一并作废
74
+ /* ⚠️ 认领标志用 **null** 而不是空串:`qaActiveId` 可能还没有(新会话 / 预览态没点过任何会话),
75
+ 那时 taskId 就是 "",用 `if(!touched) return` 判「没认领」会把这种卡的收尾整条吞掉 ——
76
+ 卡摘了、停靠位却还亮着(空一条白框钉在输入框上方)。2026-09-30 由 verify-chat-flush 实测咬出来。 */
77
+ let touched = null;
78
+ qaAskDockCards.forEach((list, tid)=>{
79
+ const i = list.indexOf(card);
80
+ if(i >= 0){ list.splice(i, 1); touched = tid; }
81
+ });
82
+ if(card.parentNode) card.parentNode.removeChild(card);
83
+ if(touched === null) return;
84
+ if(!(qaAskDockCards.get(touched) || []).length){
85
+ qaAskDockCards.delete(touched);
86
+ qaAskDockParked.delete(touched); // 卡都没了,离屏缓冲也别留着
87
+ }
88
+ qaAskDockSync(qaActiveId || "");
89
+ }
90
+ /** 答完**稍后**再撤:留一拍让「已回答 + 你选了哪条」看得见 —— 点一下卡立刻消失会像没点上。
91
+ * ⚠️ 排队态(这一轮还在跑)**不在此列**:那要等 qaAskFlush 真发出去才撤(见那两个调用点),
92
+ * 否则用户会以为答案没送出去。
93
+ * ⚠️ 2026-10-10 用户:「回答完应该自动关闭并提交答案继续对话」——
94
+ * 老快照里那种「画在对话流里」的卡(停靠位机制之前存下的画面,恢复后仍由 #qa-stream 的
95
+ * 委托点得到)**一起撤**:答完的卡留在原地只把标题改成「已回答」,正是「不自动关闭」的样子。
96
+ * 撤下只动**画面**、不动对话内容 —— 答案本身早就以 `【用户选择】…` 那条消息回了会话,
97
+ * 历史里留下的是「用户那条消息」,不是这张提问卡(与停靠位上的卡同一口径)。
98
+ * 实现上不需要分叉:qaAskDockDrop 对不在名册里的卡就是「摘节点 + 直接返回」。 */
99
+ function qaAskDockRetire(card){
100
+ if(!card) return;
101
+ if(card.dataset.retiring === "1") return;
102
+ card.dataset.retiring = "1";
103
+ setTimeout(()=>qaAskDockDrop(card), 1100);
104
+ }
105
+ /** 会话删了 → 它的卡一起清(不留一张永远等不到回答的幽灵卡)。 */
106
+ function qaAskDockClear(taskId){
107
+ const id = taskId || ""; if(!id) return;
108
+ (qaAskDockCards.get(id) || []).forEach(c=>{ qaAskAutoCancel(c); if(c.parentNode) c.parentNode.removeChild(c); });
109
+ qaAskDockCards.delete(id);
110
+ qaAskDockParked.delete(id);
111
+ qaAskDockSync(qaActiveId || "");
112
+ }
113
+
114
+ function qaAskAnswered(card){ return !!card && card.dataset.answered === "1"; }
115
+ /** 收这一张卡上的答案,形状与 magic-agent 的 `EncodeAskFollowUp` **逐字对齐**:
116
+ * 【用户选择】
117
+ * - <问题原文> → <答案>
118
+ * 多选多个 label 用 `", "` 连接(Go 侧 askMultiSelectSep)。
119
+ * 为什么必须有这个前缀:答案是以**新的一条 user 消息**追加进会话的(见 agent-cli.cjs 那一节),
120
+ * 没有前缀模型会把它读成一条新需求;两端文案漂移就等于白答。
121
+ * 单问单选也不再只回一个裸标签(原先回的就是「推送到远程」四个字,模型只能靠猜)。 */
122
+ function qaAskText(card){
123
+ const qbs = [...card.querySelectorAll(".qa-ask-qb")];
124
+ const lines = qbs.map(qb=>{
125
+ const labels = [...qb.querySelectorAll(".qa-ask-opt.sel")]
126
+ .map(o=>o.dataset.label || "").filter(Boolean);
127
+ if(!labels.length) return "";
128
+ return "- " + (qb.dataset.q || "") + " → " + labels.join(", ");
129
+ }).filter(Boolean);
130
+ return lines.length ? "【用户选择】\n" + lines.join("\n") + "\n" : "";
131
+ }
132
+ function qaAskAllDone(card){
133
+ const qbs = [...card.querySelectorAll(".qa-ask-qb")];
134
+ return qbs.length > 0 && qbs.every(qb=>qb.querySelector(".qa-ask-opt.sel"));
135
+ }
136
+ function qaAskSyncSub(card){
137
+ const b = card.querySelector(".qa-ask-sub");
138
+ if(b) b.disabled = !qaAskAllDone(card);
139
+ card.querySelectorAll(".qa-ask-opt").forEach(o=>
140
+ o.setAttribute("aria-checked", o.classList.contains("sel") ? "true" : "false"));
141
+ }
142
+ /* ---------- 答满即走(2026-10-10)----------
143
+ 用户:「回答完应该自动关闭并提交答案继续对话」。
144
+ 改前:**全是单选**的卡在最后一问落定那一刻已经会自己发(qaAskPick 里那条 qaAskAllDone 判定),
145
+ 但**带可多选**的卡必须由用户点一下「提交」—— 用户把每一项都勾完之后,卡还杵在输入框上方
146
+ (只把标题改成「已回答」),看着像「点了没反应」,得自己去找那枚钮才能让对话往下走。
147
+ 改后:卡一旦「**每一问都有选择**」就自己提交(qaAskMark → 答案走 `【用户选择】…` 回会话),
148
+ 随后照旧自动撤下(qaAskDockRetire,留一拍让用户看见选了哪条)——「提交」不再是必经的一步。
149
+ ⚠️ **可多选的问留一拍**(QA_ASK_AUTO_MS):勾第一项就发等于把第二项堵死(多选本来就允许勾好几条),
150
+ 所以这种卡改走**计时器** —— 卡上再来任何一次点选就重新计时(用户还在挑就绝不发),
151
+ 停手 1.5s 才把答案发出去。全是单选没有任何歧义,不等待、立即发。
152
+ ⚠️ 计时器挂**卡**上(WeakMap):卡被撤下 / 被标已答 / 选择被取消 → 一律先取消计时,
153
+ 绝不出现「卡都没了、答案还在半路」的幽灵提交。
154
+ ⚠️ 老快照里画在对话流里的卡走的是**同一个**出口(它是同一个 qaAskPick),不必另写一套。 */
155
+ const QA_ASK_AUTO_MS = 1500;
156
+ const qaAskAutoTimers = new WeakMap();
157
+ function qaAskAutoCancel(card){
158
+ const t = card && qaAskAutoTimers.get(card);
159
+ if(t){ clearTimeout(t); qaAskAutoTimers.delete(card); }
160
+ }
161
+ /** 选择变了就调它:答满 → 提交(可多选的卡延后 QA_ASK_AUTO_MS,给用户接着勾的时间)。 */
162
+ function qaAskAutoArm(card){
163
+ if(!card) return;
164
+ qaAskAutoCancel(card);
165
+ if(qaAskAnswered(card) || !qaAskAllDone(card)) return;
166
+ const fire = ()=>{
167
+ qaAskAutoTimers.delete(card);
168
+ if(!qaAskAnswered(card) && qaAskAllDone(card)) qaAskMark(card, qaAskText(card));
169
+ };
170
+ const multi = [...card.querySelectorAll(".qa-ask-qb")].some(qb=>qb.dataset.multi === "1");
171
+ if(!multi){ fire(); return; } // 全单选:最后一问落定 = 答完,立即发
172
+ qaAskAutoTimers.set(card, setTimeout(fire, QA_ASK_AUTO_MS));
173
+ }
174
+ /** 标记「这一问已答」并把答案发出去。
175
+ * text = 真正发出去的内容(`【用户选择】…` 信封,见 qaAskText);
176
+ * display = 卡上「你的回答」那一行显示什么(只有「其他」自由输入需要它:
177
+ * 信封里的题面不适合当展示文案;不传就不显示这行,选项高亮本身已经是答案)。 */
178
+ function qaAskMark(card, text, display){
179
+ const t = String(text || "").trim(); if(!card || !t) return;
180
+ qaAskAutoCancel(card); // 已答 → 任何「答满即走」的计时都作废(人工点「提交」也走这里)
181
+ card.dataset.answered = "1";
182
+ const ttl = card.querySelector(".qa-ask-ttl"); if(ttl) ttl.textContent = "提问 · 已回答";
183
+ const shown = String(display == null ? "" : display).trim();
184
+ if(shown){
185
+ const ans = card.querySelector(".qa-ask-ans");
186
+ if(ans){ ans.hidden = false; ans.querySelector(".v").textContent = shown; }
187
+ }
188
+ qaAskSyncSub(card);
189
+ /* sid / rid 从**卡自己**身上取(dataset,见 askCard):这样快照恢复出来的历史卡也答得回去。
190
+ 卡本身也一起交给投递口(`card`)—— 它决定「什么时候把停靠位上的这张卡撤下来」:
191
+ 立即发出去 → 留一拍就撤;排队等这轮结束 → 等真发出去那一刻才撤(见 qaAskDeliver / qaAskFlush)。 */
192
+ qaAskDeliver(qaActiveId, t, { sid: card.dataset.sid || "", rid: card.dataset.rid || "", card });
193
+ }
194
+ /** 把答案交给会话:**这条会话**空闲立即发;它自己的那一轮还在跑就排队(收尾/切回时由
195
+ * qaAskFlush 发出)。别的会话在跑**不拦它**(2026-09-18 晚定稿:锁只跟会话走,跨会话不互锁)。
196
+ * 一轮里被问了多次(多张卡)→ 按先后顺序逐条发:前一条发出后新一轮又跑起来,
197
+ * 后一条继续排队,等那一轮收尾再发(同一把闸门,不绕开「生成中不发下一条」)。 */
198
+ function qaAskDeliver(taskId, text, opts){
199
+ const id = taskId || qaActiveId || "";
200
+ const o = opts || {};
201
+ if(qaTaskRunning(id)){
202
+ const q = qaAskQueue.get(id) || [];
203
+ q.push({ text, sid: o.sid || "", rid: o.rid || "", card: o.card || null });
204
+ qaAskQueue.set(id, q);
205
+ /* 2026-10-10 用户:「回答完应该自动关闭并提交答案继续对话」——
206
+ 改前:排队态**故意不撤卡**(怕用户以为没点上)。可用户看到的是「每一项都答完了,
207
+ 框子还杵在输入框上方」,于是再来点一次「提交」、或者干脆把这一轮停掉 —— 那才是真的误会。
208
+ 改后:**答完就撤**(与空闲时同一拍),排队的痕迹改由**待发队列行**承担
209
+ (`已选答案 · 这一轮结束后自动发出`,见 qaMsgQueueRender)——「还没发出去」这件事
210
+ 照样看得见,但不再占着输入框上方那块地方。 */
211
+ if(typeof qaMsgQueueRender === "function") qaMsgQueueRender(id);
212
+ toast("已选:<b>" + escHtml(qaAskQueueLine(text)) + "</b><br>这一轮结束后自动发出", true);
213
+ qaAskDockRetire(o.card);
214
+ return;
215
+ }
216
+ toast("已选:<b>" + escHtml(qaAskQueueLine(text)) + "</b>", true);
217
+ qaAskSend(text, o);
218
+ qaAskDockRetire(o.card); // 2026-09-30:停靠位上的这张卡答完就撤(留一拍可读)
219
+ }
220
+
221
+ /** 答案的统一出口:**能追加就追加**(答案落进那条还活着的会话、引擎当下一轮处理),
222
+ * 追加不了就新起一轮 + `--session` 续接(老版 CLI / 会话已结束 / 没有会话 id)。
223
+ * 判据是「拿到 sid 且拿到 rid」:sid 来自统一 ask 事件(tool_use 那条老路没有它)——
224
+ * 所以老引擎自动走回落路径,行为与改造前完全一致。 */
225
+ function qaAskSend(text, opts){
226
+ const o = opts || {};
227
+ const sid = String(o.sid || "").trim();
228
+ /* ⚠️ rid 从 dataset 读回来是**字符串**,而事件路由(qaRuns / bindHomeAsk 的 `qaRuns.get(ev.rid)`)
229
+ 用的是**数字** —— 不归一化就会「qaRuns.get(1) 查不到 '1'」,追加回来的那一轮整轮被当过期
230
+ 事件丢掉(界面表现:追加成功、却永远看不到引擎的回答)。 */
231
+ const ridNum = (o.rid == null || o.rid === "") ? NaN : Number(o.rid);
232
+ if(sid && Number.isFinite(ridNum) && window.desk && typeof window.desk.askAppend === "function"){
233
+ qaSend(text, { followup: true, from: "home", append: { sid, rid: ridNum } });
234
+ return;
235
+ }
236
+ qaSend(text, { followup: true, from: "home" });
237
+ }
238
+ /** 排队的答案该不该现在发:**这条会话**没有一轮在跑 + 用户正看着这条会话
239
+ * (qaSend 是按 qaActiveId 落笔的,不能在别的会话的画布里替它发)。
240
+ * 别的会话还在跑**不拦** —— 答案发给的是空闲的这条,并行是会话之间的事。 */
241
+ function qaAskFlush(taskId){
242
+ const id = taskId || "";
243
+ const q = qaAskQueue.get(id);
244
+ if(!q || !q.length || qaTaskRunning(id)) return;
245
+ if(id !== (qaActiveId || "")) return;
246
+ const item = q.shift();
247
+ if(!q.length) qaAskQueue.delete(id);
248
+ qaAskSend(item.text, { sid: item.sid, rid: item.rid });
249
+ qaAskDockRetire(item.card); // 2026-09-30:排队的那张卡,等真发出去的这一刻才撤
250
+ /* 2026-10-10:答案发走了 → 待发队列里那一行「已选答案」同步消失(见 qaMsgQueueRender)。 */
251
+ if(typeof qaMsgQueueRender === "function") qaMsgQueueRender(id);
252
+ }
253
+
254
+ /* ---------- 会话排队:处理中提交的下一条 ----------
255
+ 用户 2026-09-18:「会话处理中时 改成支持输入 但是新需求排列在会话框上面默认等待上一个
256
+ 结束后自动提交」。
257
+ 改前:这一轮没跑完,撰写区整块锁死(输入域 disabled + 提交钮变停止钮)—— 想追问只能干等。
258
+ 改后:输入域照常可写(qaBusyEx 不再 disabled 它),处理中点发送 = **排进队列**:
259
+ · 条目渲染在**会话框上方**(#home-queue,与附件条同在 .home-entry 里),标「等待中」;
260
+ · 这一轮收尾(onDone / onError / 用户点停止)或用户切回这条会话时,由 qaMsgFlush
261
+ **自动提交队首那一条** —— 这就是用户说的「默认等待上一个结束后自动提交」。
262
+ ⚠️ 一次只放一条出去:每条消息都会起**新的一轮**,一次放多条 = 后一条把前一条杀掉
263
+ (主进程 `desk:ask` 是单会话槽)。剩下的等那一轮收尾再放 —— 队列自己就串起来了。
264
+ ⚠️ 提问卡答案优先:qaAskQueue 里还有没发出去的答案时先不发队列消息(那是引擎正等着的
265
+ 回答,抢在它前面发会把这一问的上下文冲掉)。
266
+ ⚠️ 只在**用户正看着这条会话**时发:qaSend 是按 qaActiveId 落笔的(画布只有一份),
267
+ 在别的会话的画布里替它发就是张冠李戴 —— 与 qaAskFlush 同一条判据。切回来即发。
268
+ ⚠️ 队列条目只存正文(`{ text }`):处理中贴的图留在**附件条**上(2026-10-19 起处理中
269
+ 贴图不再被拦,见 19-attachments 的 askAttBind),收尾后 qaMsgFlush 走 qaSend
270
+ (from = 本实例)时才被取走 —— 贴图与排队的那条一起发出。 */
271
+ const qaMsgQueue = new Map(); // taskId → [{ text }]
272
+ /** 队列条目画在**哪只实例**上:队列跟会话走,而「会话框」= home 实例。
273
+ * home2 是新建对话入口 —— 它提交的永远是**新会话**,不排队,也不该显示别人的队列。 */
274
+ const QA_QUEUE_INSTANCE = "home";
275
+ function qaMsgQueueRender(taskId){
276
+ const el = $("#" + QA_QUEUE_INSTANCE + "-queue");
277
+ if(!el) return;
278
+ const id = taskId == null ? (qaActiveId || "") : taskId;
279
+ // 只画「当前显示的这条会话」的队列:切到别的会话时不该看见上一条会话的待发条目
280
+ const showing = !!id && id === (qaActiveId || "");
281
+ const asks = showing ? (qaAskQueue.get(id) || []) : [];
282
+ const list = showing ? (qaMsgQueue.get(id) || []) : [];
283
+ el.hidden = !(asks.length || list.length);
284
+ el.innerHTML = "";
285
+ /* 2026-10-10:**已回答但还没发出去的答案**也画在这里(用户:「回答完应该自动关闭并提交
286
+ 答案继续对话」)。卡答完就撤了,排队的痕迹必须另有出处 —— 就是这一行:
287
+ 「答」字圆牌 + 「题 → 答案」摘要 + 如实写「这一轮结束后自动发出」。
288
+ ⚠️ 不给它 × :这一行是那个答案**唯一的痕迹**,点掉就等于让用户把答案悄悄丢了;
289
+ 要取消就等这一轮收尾(或停掉这一轮,答案会立刻发出)。 */
290
+ asks.forEach((it)=>{
291
+ const row = document.createElement("div");
292
+ row.className = "ag-q ag-q-ask";
293
+ row.innerHTML = `<span class="no"></span><span class="tx"></span>`
294
+ + `<span class="st">已选答案 · 这一轮结束后自动发出</span>`;
295
+ row.querySelector(".no").textContent = "答";
296
+ row.querySelector(".tx").textContent = qaAskQueueLine(it.text);
297
+ el.appendChild(row);
298
+ });
299
+ list.forEach((it, i)=>{
300
+ const row = document.createElement("div");
301
+ row.className = "ag-q";
302
+ row.dataset.i = String(i);
303
+ row.innerHTML = `<span class="no"></span><span class="tx"></span>`
304
+ + `<span class="st">等待中 · 上一条结束后自动发送</span>`
305
+ + `<button class="x" type="button" aria-label="取消这条待发消息">×</button>`;
306
+ row.querySelector(".no").textContent = String(i + 1);
307
+ row.querySelector(".tx").textContent = it.text || "";
308
+ /* 直接绑监听(不走 #qa-stream 事件委托):这一行在画布**之外**,不参与快照重建,
309
+ 每次渲染都是新节点 —— 与附件条的 × 同一套做法。 */
310
+ row.querySelector(".x").addEventListener("click", (e)=>{ e.stopPropagation(); qaMsgQueueDrop(id, i); });
311
+ el.appendChild(row);
312
+ });
313
+ }
314
+ /** 待发队列里那一行答案摘要:把 `【用户选择】\n- 题 → 答\n` 信封压成一行(那一串给引擎看的前缀
315
+ * 在界面上是噪声;题面 + 答案本身才是用户要核对的)。 */
316
+ function qaAskQueueLine(text){
317
+ const body = String(text || "").replace(/^【用户选择】\s*/, "").trim();
318
+ return body.split("\n").map((s)=>s.replace(/^-\s*/, "").trim()).filter(Boolean).join(" · ");
319
+ }
320
+ /** 取消一条待发消息(点条目尾的 ×):列表空了就把这条会话的队列键删掉,不留空数组。 */
321
+ function qaMsgQueueDrop(taskId, idx){
322
+ const id = taskId || ""; if(!id) return;
323
+ const q = qaMsgQueue.get(id); if(!q || idx < 0 || idx >= q.length) return;
324
+ q.splice(idx, 1);
325
+ if(!q.length) qaMsgQueue.delete(id);
326
+ qaMsgQueueRender(id);
327
+ toast("已取消这条待发消息");
328
+ }
329
+ /** 处理中提交 → 入队(**不打断**在跑的那一轮)。 */
330
+ function qaMsgEnqueue(taskId, item){
331
+ const id = taskId || ""; if(!id) return;
332
+ const q = qaMsgQueue.get(id) || [];
333
+ q.push({ text: String((item && item.text) || "") });
334
+ qaMsgQueue.set(id, q);
335
+ qaMsgQueueRender(id);
336
+ toast("已排队 · 这一轮结束后自动发送<br><b>" + escHtml((item && item.text) || "") + "</b>", true);
337
+ }
338
+ /** 排队的下一条该不该现在发:这条会话没有一轮在跑 + 用户正看着它 + 没有待发的提问卡答案
339
+ * (见上面三条 ⚠️)。发一条即返回 —— 剩下的等这一轮收尾再来。 */
340
+ function qaMsgFlush(taskId){
341
+ const id = taskId || "";
342
+ const q = qaMsgQueue.get(id);
343
+ if(!q || !q.length || qaTaskRunning(id)) return;
344
+ if(id !== (qaActiveId || "")) return;
345
+ if((qaAskQueue.get(id) || []).length) return;
346
+ const item = q.shift();
347
+ if(!q.length) qaMsgQueue.delete(id);
348
+ qaMsgQueueRender(id);
349
+ qaSend(item.text, { followup: true, from: QA_QUEUE_INSTANCE });
350
+ }
351
+ /** 会话删了 → 它排的队也没意义了(不留悬挂条目)。 */
352
+ function qaMsgQueueDropFor(taskId){ qaMsgQueue.delete(taskId || ""); qaMsgQueueRender(); }
353
+ /** 点选项:多选只勾(每勾一下都重新计时 —— 答满即走,见 qaAskAutoArm);单选点一下即定 */
354
+ function qaAskPick(card, optEl){
355
+ if(!card || !optEl || qaAskAnswered(card)) return;
356
+ const qb = optEl.closest(".qa-ask-qb"); if(!qb) return;
357
+ if(qb.dataset.multi === "1") optEl.classList.toggle("sel");
358
+ else {
359
+ qb.querySelectorAll(".qa-ask-opt").forEach(o=>o.classList.remove("sel"));
360
+ optEl.classList.add("sel");
361
+ }
362
+ qaAskSyncSub(card);
363
+ /* 2026-10-10 用户:「回答完应该自动关闭并提交答案继续对话」——答满即走(全单选立即发;
364
+ 带可多选的卡延后一拍,给用户接着勾的时间)。撤下由 qaAskMark → qaAskDockRetire 接手。 */
365
+ qaAskAutoArm(card);
366
+ }
367
+ /** 「其他」自由输入:不在清单里的答案直接写(回车 / 点确认都走这里)。
368
+ * 发出去的是**同一份信封**(引擎只认 `【用户选择】` 前缀,见 qaAskText);卡上「你的回答」
369
+ * 那一行显示用户写的原文(信封里的题面当展示文案太啰嗦)。 */
370
+ function qaAskFree(card, val){
371
+ const t = String(val || "").trim();
372
+ if(!card || !t || qaAskAnswered(card)) return;
373
+ const qbs = [...card.querySelectorAll(".qa-ask-qb")];
374
+ const q = qbs.length === 1 ? (qbs[0].dataset.q || "") : "";
375
+ qaAskMark(card, "【用户选择】\n" + (q ? "- " + q + " → " + t : "- " + t) + "\n", t);
376
+ }
377
+
@@ -0,0 +1,158 @@
1
+ /* ---------- #qa-stream 事件委托:工具卡 / 思考条的开合 / 提问卡点选 ----------
2
+ 为什么必须委托:快照恢复(qaTaskSwitch / qaDbLoad / 启动恢复)走的是
3
+ `s.innerHTML = t.snapshot` —— DOM 节点被整份重建,绑在卡片上的监听随旧节点消失。
4
+ 表现就是「点侧栏历史任务后,WebSearch 这类工具卡点了没反应」(2026-09-16 用户报障)。
5
+ 委托挂在容器 #qa-stream 上(它从不被替换,只换 innerHTML),历史卡片照样可开合。
6
+ ⚠️ 提问卡(⑤ .qa-ask)同理:恢复出来的历史提问卡也要能点选 —— 它比工具卡更怕丢监听。
7
+ ⚠️ 2026-09-30:提问卡**不在** #qa-stream 里了(改挂输入框上方的停靠位 #qa-askdock,
8
+ 见 06-ask-answer.js「提问卡停靠位」)—— 所以同一份处理器**两个根都挂**:
9
+ #qa-stream 管对话流里的卡(工具卡 / 思考条 / 老快照恢复出来的提问卡),
10
+ #qa-askdock 管停靠位上那批。只挂一边的话,提问卡换了个爹就地变哑巴。
11
+ ⚠️ **幂等标记必须挂在根自己身上,不能用模块级变量**(2026-10-20 用户报「组内工具调用
12
+ 点击也无法展开」):移动端每打开一条会话都是 `destroy()` + 重新 `create()`(宿主页的
13
+ mountConversation),而 `#qa-stream` 是**每次新建的节点** —— 模块级 `qaStreamDelegated`
14
+ 第一次就置真,之后每个新画布都因「已绑过」被跳过 → 那块画布上零监听,
15
+ 组里的工具 / 思考条 / 复制按钮 / 折叠组全都点不动。 */
16
+ function bindQaStreamDelegation(){
17
+ const s = $("#qa-stream");
18
+ if(!s) return;
19
+ const onAskClick = e=>{
20
+ /* ⑤ 提问卡右上角的 ×(2026-09-30):卡现在停在输入框上方、会一直占着那块地方 ——
21
+ 得给用户一个「我不回答,收起来」的出口(点 × 只撤下卡,等于放弃这一问)。 */
22
+ const askX = e.target.closest && e.target.closest(".qa-ask-x");
23
+ if(askX){
24
+ const c = askX.closest(".qa-ask");
25
+ if(c && typeof qaAskDockDrop === "function"){ qaAskDockDrop(c); toast("已收起这个确认框"); }
26
+ return;
27
+ }
28
+ // ⑤ 提问卡:选项 / 提交 / 「其他」确认(先于工具卡判定 —— 卡里没有 .qa-tool-head,互不干扰)
29
+ const askOpt = e.target.closest && e.target.closest(".qa-ask-opt");
30
+ if(askOpt){ const c = askOpt.closest(".qa-ask"); if(c) qaAskPick(c, askOpt); return; }
31
+ const askSub = e.target.closest && e.target.closest(".qa-ask-sub");
32
+ if(askSub){
33
+ const c = askSub.closest(".qa-ask");
34
+ if(c && !qaAskAnswered(c) && qaAskAllDone(c)) qaAskMark(c, qaAskText(c));
35
+ return;
36
+ }
37
+ const askGo = e.target.closest && e.target.closest(".qa-ask-go");
38
+ if(askGo){
39
+ const c = askGo.closest(".qa-ask");
40
+ const inp = c && c.querySelector(".qa-ask-other input");
41
+ if(c) qaAskFree(c, inp && inp.value);
42
+ return;
43
+ }
44
+ /* ③ 折叠组的开合(所有 `<details>`:段行 / 收尾大组 `.qa-turn`、回放条 `.qa-hist-step`)。
45
+ ⚠️ 这里**本来指望原生**:点 `<summary>` 自己就翻(03-chat-message 特意用原生 details,
46
+ 零脚本、键盘可达、开合态写进 HTML 不怕快照重建)。
47
+ 但 2026-10-18 用户报「工具无法点击展开」—— 手机上点了没反应。
48
+ 排查:桌面上(新 Chromium)程序点击、鼠标、触摸都正常翻;皮肤里这些 `summary` 都设了
49
+ `display:flex`,而 **老 WebView / 老 Chromium 对 details/summary 上的 `display` 支持不完整**
50
+ (Chrome 131 才正式支持在 details/summary 上用 display,见 Chrome 官方博客
51
+ 「More options for styling <details>」)—— 于是那台机器上原生那一下不发生了。
52
+ 现在**自己翻**:`preventDefault()` 掉原生那一下,再手动切 `open` ——
53
+ 原生能翻的机器上也只翻一次(先掐掉原生,再自己翻),不会翻两下回到原状。
54
+ 选择器故意写宽(`#qa-stream` 里的 `<summary>` 都是我们自己的 disclosures;
55
+ 正文里若出现 markdown 的 `<details>`,接管后同样「只翻一次」,不会更坏)。 */
56
+ const sum = e.target.closest && e.target.closest("summary");
57
+ if(sum && s.contains(sum)){
58
+ const d = sum.closest("details");
59
+ if(d){ e.preventDefault(); d.open = !d.open; }
60
+ return;
61
+ }
62
+ const toolHead = e.target.closest && e.target.closest(".qa-tool-head");
63
+ if(toolHead){
64
+ const card = toolHead.closest(".qa-tool"); if(!card) return;
65
+ card.dataset.touched = "1"; // 记「用户手动开合过」,自动收起让位
66
+ card.classList.toggle("open");
67
+ return;
68
+ }
69
+ /* ②③ 复制按钮(代码面板帧 / 工具明细段,2026-09-28):
70
+ 要复制的原文落位是**固定的** —— 面板里是 `.qa-cf-bd pre`,明细段里是 `.qa-tool-sec pre`
71
+ (见 29-msg-variants 的 qaCodeFrameNode / QA_COPY_BTN)。按这个约定取文本,
72
+ 所以按钮本身不带任何 data-*,快照重建后照样能复制。
73
+ 点完切成对勾 1.2s:原来没有反馈,用户会怀疑没复制上、连点好几次。 */
74
+ const cpBtn = e.target.closest && e.target.closest(".qa-cf-copy");
75
+ if(cpBtn){
76
+ const host = cpBtn.closest(".qa-cf, .qa-tool-sec");
77
+ const pre = host && host.querySelector("pre");
78
+ if(typeof qaCopyText === "function") qaCopyText(pre ? pre.textContent : "");
79
+ cpBtn.classList.add("copied");
80
+ setTimeout(()=>cpBtn.classList.remove("copied"), 1200);
81
+ return;
82
+ }
83
+ /* 消息操作行:复制 / 重试(2026-10-09 用户:「消息要加上复制和重试」)。
84
+ 落位约定与代码面板复制按钮同源 —— 要复制的正文固定在 `.qa-t`(用户那条与引擎那条都是它,
85
+ 见 03-chat-message 的两个渲染入口),所以按钮不带任何 data-*,快照重建后照样能用。
86
+ ⚠️ 时间轴下一条消息可能有**多块正文**(写一段 → 跑工具 → 再写一段,见 03 的块说明)——
87
+ 复制要取**全部块**、按 DOM 顺序拼接,只取第一块会漏掉后半篇。
88
+ 重试的判定与回退逻辑收在 qaMsgRetry(03-chat-message)里一处,这里只做转发。 */
89
+ const actBtn = e.target.closest && e.target.closest(".qa-act");
90
+ if(actBtn){
91
+ const msg = actBtn.closest(".qa-msg");
92
+ if(msg){
93
+ if(actBtn.classList.contains("qa-act-copy")){
94
+ const parts = [...msg.querySelectorAll(".qa-t")].map(el=>el.textContent || "");
95
+ if(typeof qaCopyText === "function") qaCopyText(parts.join("\n\n").trim());
96
+ actBtn.classList.add("copied");
97
+ setTimeout(()=>actBtn.classList.remove("copied"), 1200);
98
+ }else if(actBtn.classList.contains("qa-act-retry")){
99
+ if(typeof qaMsgRetry === "function") qaMsgRetry(msg);
100
+ }
101
+ }
102
+ return;
103
+ }
104
+ /* ② 单行标记行开合(图标 + 人话标题 + 状态徽章 + 可展开明细)。
105
+ ⚠️ 没有明细(data-tog="0")时不可点 —— 否则点了一下什么都没有,像坏了。 */
106
+ const mkRow = e.target.closest && e.target.closest(".qa-mk-row");
107
+ if(mkRow){
108
+ if(mkRow.dataset.tog !== "1") return;
109
+ const mk = mkRow.closest(".qa-mk"); if(!mk) return;
110
+ mk.dataset.touched = "1";
111
+ mk.classList.toggle("open");
112
+ return;
113
+ }
114
+ const thinkTg = e.target.closest && e.target.closest(".qa-think-toggle");
115
+ if(thinkTg){
116
+ const th = thinkTg.closest(".qa-think"); if(!th) return;
117
+ th.dataset.touched = "1";
118
+ th.classList.toggle("open");
119
+ return;
120
+ }
121
+ const toolsTg = e.target.closest && e.target.closest(".qa-tools-toggle");
122
+ if(toolsTg){
123
+ const ts = toolsTg.closest(".qa-tools"); if(!ts) return;
124
+ ts.dataset.touched = "1";
125
+ ts.classList.toggle("open");
126
+ return;
127
+ }
128
+ /* ⑥ 任务列表开合:面板是应答里的一块**常驻**节点(一条应答只有一块,每次 TodoWrite
129
+ 原地更新它的内容,见 03-chat-message 的 todoList)—— 开合态只切类,点击必须走委托,
130
+ 理由与工具卡 / 思考条同:快照恢复(`s.innerHTML = t.snapshot`)会把节点整个重建,
131
+ 直接绑的监听随旧节点消失(点了没反应)。 */
132
+ const todoTg = e.target.closest && e.target.closest(".qa-todo-toggle");
133
+ if(todoTg){
134
+ const panel = todoTg.closest(".qa-todo-panel"); if(!panel) return;
135
+ panel.classList.toggle("open");
136
+ }
137
+ };
138
+ // 「其他」行的回车确认(input 的 Enter 不会走 click;同样挂在容器上才不怕快照重建)
139
+ // ⚠️ 合成中(输入法选词那一敲)不算确认,见 qaImeComposing —— 这行输入框同样要吃中文。
140
+ const onAskKeydown = e=>{
141
+ if(qaImeComposing(e)) return;
142
+ if(e.key !== "Enter") return;
143
+ const inp = e.target.closest && e.target.closest(".qa-ask-other input");
144
+ if(!inp) return;
145
+ e.preventDefault();
146
+ const c = inp.closest(".qa-ask"); if(c) qaAskFree(c, inp.value);
147
+ };
148
+ /* 两个根都挂(见文件头 2026-09-30):停靠位上的卡不在 #qa-stream 里,只挂一个根等于
149
+ 让提问卡在换位置之后变成哑巴(点击无反应、回车不确认)。
150
+ ⚠️ 幂等按**每个根自己**判(`__qaDelegated`):宿主每重建一次组件就是一批新节点,
151
+ 按模块级变量判会把后来的画布整批漏掉(见文件头那条报障)。 */
152
+ [s, $("#" + QA_ASKDOCK_ID)].forEach(root=>{
153
+ if(!root || root.__qaDelegated) return;
154
+ root.__qaDelegated = true;
155
+ root.addEventListener("click", onAskClick);
156
+ root.addEventListener("keydown", onAskKeydown);
157
+ });
158
+ }