magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -59,17 +59,70 @@ function qaNearBottom(s){ return !!s && (s.scrollHeight - s.scrollTop - s.client
59
59
  * 下一帧才滚),那一刻判 nearBottom 恒为真 → 手势永远关不掉闸,手机上还是会被拽回。
60
60
  * 反过来也不能不判:内容不足一屏时(没得滚)若把手势当真,一次误触就再也不跟随新内容了。 */
61
61
  function qaCanScroll(s){ return !!s && s.scrollHeight > s.clientHeight + 4; }
62
+ /** 贴底(「跟着新内容下移到最新」)的**唯一出口** —— 本文件每一处落笔后都调它。
63
+ * 三道闸,逐条理由:
64
+ * ① `qaPaintBg` —— 后台 run 在画它自己的 holder(不在屏幕上),不许拽动可见画布;
65
+ * ② `qaUserAway` —— 用户上滚看历史了(qaScrollGateBind 维护),别抢他的滚动位置;
66
+ * ③ rAF 合并 —— 见下。
67
+ * ⚠️ 为什么必须合并(2026-10-25 实测):流式增量是**每事件**一贴,而 `scrollHeight` 读一次
68
+ * 就是一次强制布局 —— 画布长起来之后每笔约 2ms:回放 5000 条文本事件实测 **11.8s**,
69
+ * 同一批不贴底只要 1.4s。长会话(实测有 95k 事件的)会把「加载慢」放大成分钟级卡死,
70
+ * 正是用户 2026-10-20 报过的「移动端加载也很慢」那一类。合并之后:
71
+ * · 同步循环(历史回放 / 前插翻页)里 rAF 根本不会触发 → 只注册一帧一次,
72
+ * 中间成本归零,循环结束那一帧补一次贴底,终态与逐条贴完全一致;
73
+ * · 直播路径最多「下一帧贴底」(≤16ms),肉眼无差,突发批量也不会每笔都重排。
74
+ * ⚠️ 执行前再查一次 `qaUserAway`:安排之后、帧到来之前用户滚上去了 → 这次作废
75
+ * (他的意图比这一笔增量新)。 */
76
+ let qaScrollRaf = 0;
62
77
  function qaScrollIntoView(){
63
- if(qaPaintBg) return; // 后台 run 的增量不许拽动前台画布(见 qaPaintBg)
64
- if(qaUserAway) return; // 用户跳上去看历史了,别抢他的滚动位置
78
+ if(qaPaintBg) return; // ① 后台 run 的增量不许拽动前台画布(见 qaPaintBg)
79
+ if(qaUserAway) return; // ② 用户跳上去看历史了,别抢他的滚动位置
65
80
  const s = $("#qa-stream"); if(!s) return;
66
- s.scrollTop = s.scrollHeight; // 卡片固定撑满、自身即滚动容器(滚动条已隐藏),永远贴底
81
+ if(qaScrollRaf) return; // ③ 本帧已安排过一次(同步循环里后续调用全走这条)
82
+ qaScrollRaf = requestAnimationFrame(()=>{
83
+ qaScrollRaf = 0;
84
+ if(qaUserAway) return; // 安排后用户滚上去看历史了 → 作废
85
+ const el = $("#qa-stream"); if(!el) return;
86
+ el.scrollTop = el.scrollHeight; // 卡片固定撑满、自身即滚动容器(滚动条已隐藏),永远贴底
87
+ });
67
88
  }
68
89
  /** 切会话时把「用户离开底部」复位(2026-10-20):`qaUserAway` 是**画布级**状态,而画布只有一份。
69
90
  * 在 A 里滚上去看历史,再切到 B,若不复位,B 打开时就停在顶部(看的是旧内容中间),
70
91
  * 而不是它最新的那条消息。宿主切换会话时调用(见 qaTaskSwitch)。 */
71
92
  function qaScrollReset(){ qaUserAway = false; }
72
93
 
94
+ /** 打开历史会话后的「兜底贴底」(2026-10-25 用户:「历史会话也应该默认到最底部」)。
95
+ * 各宿主回放结束那次同步贴底(loadHistory / qaRailReplay 里的 scrollTop=scrollHeight)
96
+ * 发生在**布局完成之前**:回放里的截图缩略图、markdown 字体在贴底之后才加载完,
97
+ * 把 scrollHeight 撑大 → 画布停在半空。手机上图片加载慢,几乎必现(同一张图
98
+ * PC 网页有缓存所以看着正常)。
99
+ * 三重兜底,全部尊重 `qaUserAway`(用户已经自己往上翻了就别拽回来):
100
+ * ① rAF 下一帧 —— 同步回放结束后布局刚稳的那一刻;
101
+ * ② 300ms 定时 —— 缩略图大多在这前后到位;
102
+ * ③ 1.5s 窗口内的 img load 各再贴一次(慢图;窗口过期后顺手摘监听)。
103
+ * ⚠️ 重复调用先摘上一个 img 监听(#qa-stream 节点常驻不替换、只换 innerHTML,
104
+ * 不摘会随切换次数累积);置 qaUserAway=false 已含 qaScrollReset 的语义。 */
105
+ let qaHistStickLsn = null;
106
+ function qaHistoryStickBottom(){
107
+ qaUserAway = false;
108
+ const once = ()=>{
109
+ if(qaUserAway) return;
110
+ const s = $("#qa-stream"); if(!s) return;
111
+ s.scrollTop = s.scrollHeight;
112
+ };
113
+ requestAnimationFrame(once);
114
+ setTimeout(once, 300);
115
+ const s = $("#qa-stream"); if(!s) return;
116
+ if(qaHistStickLsn) s.removeEventListener("load", qaHistStickLsn, true);
117
+ const until = Date.now() + 1500;
118
+ qaHistStickLsn = function(e){
119
+ if(!e.target || e.target.tagName !== "IMG") return;
120
+ if(Date.now() > until){ s.removeEventListener("load", qaHistStickLsn, true); qaHistStickLsn = null; return; }
121
+ once();
122
+ };
123
+ s.addEventListener("load", qaHistStickLsn, true);
124
+ }
125
+
73
126
  /* ---------- 快速回话跳转(2026-10-12 用户:「增加一个快速跳转回话的功能 用户1个问题1个圆点」)----------
74
127
  对话流左侧一条竖排圆点轨:**一个用户提问一枚圆点** —— 数的就是流里那些 `.qa-msg.qa-mine`
75
128
  (自己说的话),引擎回答 / 机器侧 trace / 收尾行都不算,所以圆点数 = 你问过几轮。
@@ -348,9 +401,23 @@ function qaBotAvatarHtml(){
348
401
  前缀只是 App / CLI 内部区分自定义端点的约定,对用户是噪点;`--engines` 的清单早就
349
402
  剥过了(agent.StripCustomLocalPrefix),但**会话登记表 / start 事件 / 引擎 result 行**
350
403
  里仍是完整 id —— 那些是传给 `-m` 与落库的值,不能改,只能在渲染这一层剥。
351
- ⚠️ 只用于显示:`-m`、任务落库、payload 一律仍用完整 id(见 codebuddyCore.modelArg)。 */
352
- function qaModelText(id){
353
- return String(id == null ? "" : id).trim().replace(/^custom-local:/, "");
404
+ ⚠️ 只用于显示:`-m`、任务落库、payload 一律仍用完整 id(见 codebuddyCore.modelArg)。
405
+ dsh 的 id 是 `route/model`(路由前缀只在 dsh 配置内部有区分意义),显示时只留后面的
406
+ 模型名 —— `-m` 仍用完整 id(见 dsh.go::dshResolveRoute)。engine 省略时不做这一步,
407
+ 因为调用方可能拿它当查表键(倍率 / 标签表都用完整 id)。 */
408
+ function qaModelText(id, engine){
409
+ const s = String(id == null ? "" : id).trim().replace(/^custom-local:/, "");
410
+ if(String(engine == null ? "" : engine).trim().toLowerCase() === "dsh") return s.replace(/^[^/]+\//, "");
411
+ return s;
412
+ }
413
+ /* 模型 id → 归属路由(dsh 的 `route/model` 里那段 route;其它引擎 / 不带斜杠 → 空串)。
414
+ 只给**选择器**做两层显示用:同名模型可能挂在多条路由下(modelverse 与 volcengine
415
+ 都有 deepseek-v4.1-flash-1017),单看末段分不出是哪一个。消息里的小标仍只显示模型名。 */
416
+ function qaModelRoute(id, engine){
417
+ if(String(engine == null ? "" : engine).trim().toLowerCase() !== "dsh") return "";
418
+ const s = String(id == null ? "" : id).trim();
419
+ const i = s.indexOf("/");
420
+ return i > 0 ? s.slice(0, i) : "";
354
421
  }
355
422
  /* 模型 → **展示用倍率**(`"×0.34"`);没有倍率(引擎不给数据 / 该模型不计费)→ `""`。
356
423
  数据来自 `--engines` 的 `model_credits`(见 internal/cli/root.go):键与 `models[]` 同形,
@@ -377,6 +444,78 @@ function qaModelCredit(engines, engine, id){
377
444
  for(const e of list){ const c = read(e); if(c) return c; }
378
445
  return "";
379
446
  }
447
+ /* 模型 → **供应商展示名**(如 `"Volcengine"`);没有数据 → `""`。
448
+ 数据来自 `--engines` 的 `model_vendors`(见 internal/cli/root.go,目前只有 codebuddy 系:
449
+ 取自它自己 models.json 的 vendor 字段)。只用于**选择器第二行**(与 dsh 的路由同一位置、
450
+ 同一视觉),不参与 -m / 落库。键与 models[] 同形(已剥 custom-local:)。
451
+ PC 宿主可能归一成 camelCase,两种拼写都认。
452
+ ⚠️ 只给 models.json 里登记了 vendor 的自定义模型显示;codebuddy 官方模型没有厂商字段
453
+ (缓存里只有内部路由码),按用户要求不推断、不显示第二行。 */
454
+ function qaModelVendor(engines, engine, id){
455
+ const key = qaModelText(id);
456
+ if(!key) return "";
457
+ const list = Array.isArray(engines) ? engines : [];
458
+ const read = (e)=>{
459
+ const map = (e && (e.model_vendors || e.modelVendors)) || null;
460
+ if(!map || typeof map !== "object") return "";
461
+ return String(map[key] == null ? "" : map[key]).trim();
462
+ };
463
+ if(engine){
464
+ for(const e of list) if(e && e.engine === engine) return read(e);
465
+ return "";
466
+ }
467
+ for(const e of list){ const v = read(e); if(v) return v; }
468
+ return "";
469
+ }
470
+ /* 选择器里的模型顺序(**仅展示层**):
471
+ ① `auto` 固定第一;
472
+ ② 其余按显示名**字母顺序**(不区分大小写,数字按自然序:`…-2` 在 `…-10` 前)。
473
+ 2026-10-26 用户:「引擎的显示现在是乱序的 按照字母顺序排」—— 之前按「官方在前 + 积分倍率」
474
+ 排,视觉上是一团乱;现在只按名字排,同一引擎的模型一眼能找。 */
475
+ function qaModelsOrdered(engines, engine, models){
476
+ const list = Array.isArray(models) ? models.slice() : [];
477
+ if(list.length < 2) return list;
478
+ /* 自然序键:数字段补零到 12 位,`gpt-4` < `gpt-10`(纯字典序会反过来)。 */
479
+ const natKey = function(id){
480
+ return qaModelText(id, engine).toLowerCase()
481
+ .replace(/\d+/g, function(d){ return ("000000000000" + d).slice(-12); });
482
+ };
483
+ list.sort(function(a, b){
484
+ if(a === "auto") return -1;
485
+ if(b === "auto") return 1;
486
+ const ka = natKey(a), kb = natKey(b);
487
+ if(ka === kb) return 0;
488
+ return ka < kb ? -1 : 1;
489
+ });
490
+ return list;
491
+ }
492
+ /* 模型 → **套餐额度快照**(如 `"5h 98% · 周 53%"`);没有数据 → `""`。
493
+ 数据来自 `--engines` 的 `model_quota`(model id → `{interval_left, weekly_left}`,
494
+ 键与 `models[]` 同形)+ `credit_suffix`(单位,缺省 `%`)。目前只有 llm 的 MiniMax
495
+ 模型有:同一账号一份快照,core 把它挂到每个走该端点的模型上;别的供应商模型没有。
496
+ PC 宿主的 glue 可能把字段归一成 camelCase,两种拼写这里都认。 */
497
+ function qaModelQuota(engines, engine, id){
498
+ const key = qaModelText(id);
499
+ if(!key) return "";
500
+ const list = Array.isArray(engines) ? engines : [];
501
+ for(const e of list){
502
+ if(!e || (engine && e.engine !== engine)) continue;
503
+ const map = e.model_quota || e.modelQuota || null;
504
+ if(!map || typeof map !== "object") continue;
505
+ const q = map[key];
506
+ if(!q || typeof q !== "object") continue;
507
+ const read = (a, b)=>String((a != null ? a : (b == null ? "" : b))).trim();
508
+ const iv = read(q.interval_left, q.intervalLeft);
509
+ const wk = read(q.weekly_left, q.weeklyLeft);
510
+ if(!iv && !wk) continue;
511
+ const unit = e.credit_suffix || e.creditSuffix || "%";
512
+ const parts = [];
513
+ if(iv) parts.push("5h " + iv + unit);
514
+ if(wk) parts.push("周 " + wk + unit);
515
+ return parts.join(" · ");
516
+ }
517
+ return "";
518
+ }
380
519
  /* **引擎名小标**(2026-09-22 用户:「头像旁边显示引擎名字」;同日追加「名字显示的大一点
381
520
  再加上模型显示」→ 文案 = 「引擎 · 模型」,字号见 app.css M8):左侧消息头像独占一行(见文末 M7)
382
521
  之后,头像那一行右侧空着 —— 把「这一条是哪个引擎、哪个模型说的」挂在头像右边。
@@ -394,7 +533,7 @@ function qaWhoLabel(engine, model, modelOnly){
394
533
  const nm = String(engine == null ? "" : engine).trim();
395
534
  if(!nm) return "";
396
535
  const md = String(model == null ? "" : model).trim();
397
- const mName = md ? qaModelName(qaModelText(md), nm) : "";
536
+ const mName = md ? qaModelName(qaModelText(md, nm), nm) : "";
398
537
  if(modelOnly) return mName || nm;
399
538
  return mName ? `${nm} · ${mName}` : nm;
400
539
  }
@@ -505,9 +644,16 @@ function qaRenderMine(text, atts, opts){
505
644
  const row = document.createElement("div");
506
645
  row.className = "qa-atts";
507
646
  atts.forEach((a)=>{
508
- const u = attThumbUrl(a);
647
+ /* 实时附件(remoteUrl/objectUrl 在)走 attThumbUrl,行为与以前逐像素一致;
648
+ 历史附件只有 {path, mime} —— 走 attHistUrl / attHistName 这条取址链
649
+ (见 19-attachments 的「历史附件」段),否则点开历史会话参考图整排消失。 */
650
+ const live = !!(a && (a.remoteUrl || a.objectUrl));
651
+ const u = live ? attThumbUrl(a) : attHistUrl(a);
509
652
  if(!u) return;
510
- row.appendChild(qaThumbNode(u, (a.name || "截图") + " · " + attSize(a.size), a.name || "截图"));
653
+ if(!live && !attHistIsImage(a)) return; // 历史档的非图附件没有缩略形态,不画破图
654
+ const nm = attHistName(a);
655
+ const sz = Number(a && a.size) > 0 ? " · " + attSize(Number(a.size)) : "";
656
+ row.appendChild(qaThumbNode(u, nm + sz, nm));
511
657
  });
512
658
  if(row.children.length) host.appendChild(row);
513
659
  }
@@ -598,6 +744,8 @@ function qaRenderAnswer(host){
598
744
  否则这一轮里后起的机器块会落错地方(旧实现预置了空的思考块/工具组,恰好掩盖了这点)。 */
599
745
  const machineHost = ()=> (div.__traceWrap && div.__traceWrap.querySelector(".qa-bd")) || bd;
600
746
  let curThink = null, curGroup = null, curText = null; // 当前「打开」的三类块
747
+ /* 本回合**最后一次**建出的思考块(含已定格的那些)——「交替推理」要往里续,见 ensureThink。 */
748
+ let lastThink = null;
601
749
  /* 本条消息里**建过的所有**工具组(不只当前那个)——收尾兜底要能封到「被 endToolRun 摘掉
602
750
  引用、之后又被卡片落定重新点亮」的那种组(见 settleGroups 的说明)。 */
603
751
  const groups = [];
@@ -727,7 +875,15 @@ function qaRenderAnswer(host){
727
875
  (2026-09-16 用户报障:WebSearch 点击无法展开)。用户手动开合过 → 记 data-touched。 */
728
876
  function ensureThink(){
729
877
  if(curThink) return curThink;
730
- endToolRun(); sealText(); // 换块:正文与这一段工具到此为止
878
+ /* ⚠️ **交替推理不能封正文**(2026-10-25 用户报障「乱显示了」):
879
+ codebuddy + glm-5.3-flash 实测把思考与正文**逐字交替**吐(thinking → text →
880
+ thinking → text…,同一 message id、见会话日志)。原实现无条件 `sealText()`,
881
+ 于是每个几字的 text 增量都被封成一块 → 画布上就是
882
+ 「半句话 / 任务耗时 1 项过程 / 半句话」一路铺下来。
883
+ 正文正在进行时思考只**旁挂**(思考块为收起态,正文继续在同一块里追加)。 */
884
+ if(!curText){
885
+ endToolRun(); sealText(); // 换块:正文与这一段工具到此为止
886
+ }
731
887
  const el = document.createElement("div");
732
888
  el.className = "qa-think";
733
889
  el.hidden = true;
@@ -736,6 +892,7 @@ function qaRenderAnswer(host){
736
892
  curThink = { el, tt: el.querySelector(".tt"), dur: el.querySelector(".qa-think-dur"),
737
893
  body: el.querySelector(".qa-think-bd"), start: 0, timer: null,
738
894
  touched: ()=> el.dataset.touched === "1" };
895
+ lastThink = curThink;
739
896
  return curThink;
740
897
  }
741
898
  function thinkBegin(c){
@@ -865,7 +1022,7 @@ function qaRenderAnswer(host){
865
1022
 
866
1023
  const run = document.createElement("div");
867
1024
  run.className = "qa-run";
868
- run.innerHTML = qaRunPillHtml(`${qaEngine}${qaModel ? " · " + qaModelName(qaModelText(qaModel), qaEngine) : ""} · 生成中`);
1025
+ run.innerHTML = qaRunPillHtml(`${qaEngine}${qaModel ? " · " + qaModelName(qaModelText(qaModel, qaEngine), qaEngine) : ""} · 生成中`);
869
1026
  s.appendChild(run);
870
1027
  /* ⚠️ 落笔即贴底(2026-09-23,与 qaRenderMine / qaRenderDone 同一约定)。
871
1028
  为什么必须在**这一行之后**:这条应答的立身有两块 —— 气泡节点(上面那个 div)+ 这枚
@@ -1184,6 +1341,11 @@ function qaRenderAnswer(host){
1184
1341
  function askCard(name, qs, opts){
1185
1342
  const list = Array.isArray(qs) ? qs : [];
1186
1343
  const o = opts || {};
1344
+ /* 待授权(kind=permission,2026-10-25):与提问共用同一张卡,只换措辞与作答出口
1345
+ (允许 / 拒绝 → control 的 ask_answer;见 06-ask-answer 的 qaAskMark)。
1346
+ 计划审批 ExitPlanMode 以前在界面上**什么都没有**(用户:「没看到审批」),
1347
+ 而那一轮其实正卡在 CLI 里等 control_response。 */
1348
+ const isPerm = o.kind === "permission";
1187
1349
  const multi = list.length > 1 || list.some(q=>q.multiSelect);
1188
1350
  /* 收笔那枚「提交」钮**只在卡里有可多选的问时才画**(2026-10-10):单选是明确的落笔,
1189
1351
  答满即走(见 06-ask-answer.js::qaAskAutoArm),摆一枚用不上的钮只会让人以为还差一步;
@@ -1194,26 +1356,35 @@ function qaRenderAnswer(host){
1194
1356
  card.title = String(o.title || "").slice(0, 600);
1195
1357
  /* 答案要回到哪条会话 —— 落在 **dataset** 上而不是闭包里:
1196
1358
  快照恢复走 `innerHTML = snapshot`,闭包随旧节点一起丢,dataset 跟着 HTML 活下来,
1197
- 所以恢复出来的历史提问卡照样答得回去(REQ-22 锁的就是「已答态随快照恢复」)。 */
1359
+ 所以恢复出来的历史提问卡照样答得回去(REQ-22 锁的就是「已答态随快照恢复」)。
1360
+ 待授权还多两样:引擎 control_request 的 request_id(回 control_response 必须原样带回)
1361
+ 与工具名(只用于回执/排障)。 */
1198
1362
  if(o.sid) card.dataset.sid = String(o.sid);
1199
1363
  if(o.rid != null && o.rid !== "") card.dataset.rid = String(o.rid);
1364
+ if(isPerm){
1365
+ card.dataset.askkind = "permission";
1366
+ if(o.requestId) card.dataset.requestId = String(o.requestId);
1367
+ if(o.toolName) card.dataset.toolName = String(o.toolName);
1368
+ }
1200
1369
  card.innerHTML = `
1201
1370
  <div class="qa-ask-hd">
1202
1371
  <span class="qa-ask-ico">${QA_ICONS.ask}</span>
1203
1372
  <span class="qa-ask-tag"></span>
1204
- <span class="qa-ask-ttl">提问 · 待回答</span>
1373
+ <span class="qa-ask-ttl">${isPerm ? "审批 · 待确认" : "提问 · 待回答"}</span>
1205
1374
  <button type="button" class="qa-ask-x" title="收起这个确认框(不回答)" aria-label="收起这个确认框">×</button>
1206
1375
  </div>
1207
1376
  <div class="qa-ask-list"></div>
1208
1377
  <div class="qa-ask-other">
1209
- <input type="text" placeholder="其他:不在上面选项里就直接写,回车确认">
1210
- <button type="button" class="qa-ask-go">确认</button>
1378
+ <input type="text" placeholder="${isPerm ? "不同意就说说要改什么,回车 = 拒绝并提交意见" : "其他:不在上面选项里就直接写,回车确认"}">
1379
+ <button type="button" class="qa-ask-go">${isPerm ? "拒绝并说明" : "确认"}</button>
1211
1380
  </div>
1212
1381
  <div class="qa-ask-ft"${multi ? "" : " hidden"}>
1213
1382
  ${needSubmit ? '<button type="button" class="qa-ask-sub" disabled>提交</button>' : ""}
1214
- <span class="qa-ask-hint">${needSubmit
1215
- ? (list.length > 1 ? "共 " + list.length + " 问 · 可多选,勾完自动提交(也可点提交)" : "可多选,勾完自动提交(也可点提交)")
1216
- : "共 " + list.length + " 问,都选完自动提交"}</span>
1383
+ <span class="qa-ask-hint">${isPerm
1384
+ ? "点「允许」或「拒绝」,选完自动提交"
1385
+ : (needSubmit
1386
+ ? (list.length > 1 ? "共 " + list.length + " 问 · 可多选,勾完自动提交(也可点提交)" : "可多选,勾完自动提交(也可点提交)")
1387
+ : "共 " + list.length + " 问,都选完自动提交")}</span>
1217
1388
  </div>
1218
1389
  <div class="qa-ask-ans" hidden><span class="k">你的回答</span><span class="v"></span></div>`;
1219
1390
  /* ⚠️ 2026-09-22 审美修订:这枚标签原来**直接把引擎的工具名画在卡头上**
@@ -1222,8 +1393,17 @@ function qaRenderAnswer(host){
1222
1393
  工具名降级进 title(排障时鼠标一悬停仍能看到是哪个工具)。 */
1223
1394
  const tagEl = card.querySelector(".qa-ask-tag");
1224
1395
  if(tagEl){
1225
- tagEl.textContent = "需要你确认";
1226
- tagEl.title = "引擎提问工具:" + String(name || "AskUserQuestion");
1396
+ tagEl.textContent = isPerm ? "需要你审批" : "需要你确认";
1397
+ tagEl.title = (isPerm ? "引擎审批工具:" : "引擎提问工具:") + String(name || "AskUserQuestion");
1398
+ }
1399
+ /* 待授权的 × 要如实说清后果:引擎正阻塞等这一裁决,收起卡 = 这一轮会一直等到超时
1400
+ (或用户点停止)。提问卡没这个语义(模型问完通常自己往下走了)。 */
1401
+ if(isPerm){
1402
+ const x = card.querySelector(".qa-ask-x");
1403
+ if(x){
1404
+ x.title = "收起(不回答:这一轮会继续等,直到你点停止或超时)";
1405
+ x.setAttribute("aria-label", "收起这个审批卡(不回答)");
1406
+ }
1227
1407
  }
1228
1408
  const listEl = card.querySelector(".qa-ask-list");
1229
1409
  list.forEach((q, qi)=>{
@@ -1276,7 +1456,7 @@ function qaRenderAnswer(host){
1276
1456
  done(){
1277
1457
  if(!qaAskAnswered(card)){
1278
1458
  const ttl = card.querySelector(".qa-ask-ttl");
1279
- if(ttl) ttl.textContent = "提问 · 待回答";
1459
+ if(ttl) ttl.textContent = isPerm ? "审批 · 待确认" : "提问 · 待回答";
1280
1460
  }
1281
1461
  settled = true;
1282
1462
  qaScrollIntoView();
@@ -1571,12 +1751,35 @@ function qaRenderAnswer(host){
1571
1751
  endThinking(){ thinkEnd(); },
1572
1752
  beginTools(){ groupBegin(); },
1573
1753
  endTools(){ groupEnd(); },
1574
- appendThinking(t){ const c = thinkBegin(); c.body.textContent += t; },
1754
+ /* ⚠️ 这两个流式追加出口**必须贴底**(2026-10-25 用户报障:「会话运行中显示不会自动跟着
1755
+ 消息返回下移到底端」):本文件其余落笔处(工具卡 / 思考块开关 / 标记行 / 收尾行…)
1756
+ 每一处都跟着一句 qaScrollIntoView,唯独流式增量这两条没有 —— 组件宿主(移动端 /
1757
+ PC 网页)的 render → kernelApply → qaStreamApply → appendText 全程就没有别的地方
1758
+ 再滚了。PC 宿主靠 qaStreamBind 在 qaStreamApply 之后自己补了一次(92-composer.js),
1759
+ 所以只有组件宿主坏掉;根因是渲染收敛进内核时,旧组件渲染器 pushItem 里那句
1760
+ `if (state.autoFollow) scrollBottom()` 没有对应物。补在这里 = 三端一份实现都恢复。
1761
+ 「用户上滚看历史时停止下移」由 qaScrollIntoView 里的 qaUserAway 闸门负责
1762
+ (qaScrollGateBind 维护,宿主 create / qaComposerInit 已绑)—— 闸开着才贴,
1763
+ 用户滚上去看历史时每一笔增量都直接 return,不抢他的滚动位置。 */
1764
+ appendThinking(t){
1765
+ /* 交替推理(见 ensureThink):正文正在进行时,思考**续写同一枚**思考块,
1766
+ 不再每来一跳就新起一块 —— 否则收起态下会堆出一串「已深度思考」。 */
1767
+ if(curText && lastThink && !curThink){
1768
+ lastThink.body.textContent += t;
1769
+ qaScrollIntoView();
1770
+ return;
1771
+ }
1772
+ const c = thinkBegin(); c.body.textContent += t; qaScrollIntoView();
1773
+ },
1575
1774
  appendText(t){
1775
+ /* 正文回来了 → 正亮着的思考到此定格(不然「思考中 · 12.3s」会一直转到收尾,
1776
+ 与下面不断增长的正文同框)。块不搬走、正文块也不新起,见 ensureThink。 */
1777
+ if(curThink) thinkEnd(curThink);
1576
1778
  ensureText(); // 没有当前正文块就按到达顺序新起一块
1577
1779
  pending += t;
1578
1780
  flushClosed();
1579
1781
  renderTail();
1782
+ qaScrollIntoView(); // 流式增量贴底(闸门见上面的说明)
1580
1783
  },
1581
1784
  /** 图片块:KindImage / 原生 image 块 → data URI 直接上正文区。
1582
1785
  * 图算**正文的一部分**(不是过程块):归到当前正文块、插在打字预览(.qa-tail)之前 ——
@@ -1597,6 +1800,17 @@ function qaRenderAnswer(host){
1597
1800
  qaScrollIntoView();
1598
1801
  },
1599
1802
  setResult(text){ rerenderAll(text); },
1803
+ /** 把这一轮**已经画在气泡里的正文**整段撤掉(2026-10-25)。
1804
+ * 何时用:引擎把供应商的额度/限流通告**当正文**流了下来(codebuddy 这类只发聚合行的
1805
+ * 引擎:正文随 text 增量进气泡,收尾的 turn_failed 里 body 是空串),而真正的正文
1806
+ * 一个字都没有。此时 `setResult("")` 不够用 —— 它只在「只有一个正文块」时才覆盖
1807
+ * (见 rerenderAll 的多块守卫),而通告常常跟着思考/工具分成好几段。
1808
+ * ⚠️ 只撤正文块(`.qa-t`),思考块与工具组留着 —— 那是真的跑过的过程,不是假信息。 */
1809
+ clearText(){
1810
+ textBlocks.forEach(b=>{ if(b.parentNode) b.parentNode.removeChild(b); });
1811
+ textBlocks.length = 0;
1812
+ curText = null; pending = "";
1813
+ },
1600
1814
  toolCall,
1601
1815
  agentCall,
1602
1816
  askCard, // ⑤ 提问卡(AskUserQuestion → 可点选项)
@@ -1727,11 +1941,21 @@ function qaDurText(ms){
1727
1941
  * 给了就在状态语后面拼一段「· 总耗时 7m 14s」;不给(拿不到起点 / 老调用方)只写状态语。
1728
1942
  * ⚠️ 与引擎自报的 `latency_ms` 不是一回事:那个只算引擎自己那一跑,不含排队与冷启动,
1729
1943
  * 而且引擎不给就没有 —— 收尾行要的是「这一轮一共花了多久」,所以用墙上时钟自己算。 */
1730
- function qaRenderDone(ok, label, host, elapsedMs){
1944
+ function qaRenderDone(ok, label, host, elapsedMs, detail){
1731
1945
  // host = 这一轮的落笔处(见 qaRunHost):后台跑的收尾行要落进它自己的画布,
1732
1946
  // 不能写死 #qa-stream —— 否则「切走那条跑完了」会在**当前显示的**会话里凭空多出一行收尾。
1733
1947
  const s = host || $("#qa-stream"); if(!s) return;
1734
1948
  const t = qaDurText(elapsedMs);
1949
+ // 失败终态:落成一张**设计过的错误卡**(见 29-msg-variants 的 qaErrorCardNode),
1950
+ // 而不是原来那行等宽灰字。成功 / 被停仍走下面那行轻量收尾(它们不需要卡片)。
1951
+ if(ok === false){
1952
+ /* 额度 / 限流那一轮换一张**专门的卡**(2026-10-25,见 29-msg-variants 的 qaQuotaCardNode):
1953
+ 它要说的不是「出错在哪」,而是「什么时候能再用、现在该怎么办」—— 落成普通失败卡,
1954
+ 用户看到的是一段供应商原文。判据只在 core(事件带 error_kind),这里只分流。 */
1955
+ s.appendChild(qaQuotaCardNode({ label: label, detail: detail, elapsedMs: t, info: qaQuotaInfo(detail) }));
1956
+ qaScrollIntoView();
1957
+ return;
1958
+ }
1735
1959
  const d = document.createElement("div");
1736
1960
  d.className = "qa-done" + (ok ? "" : " err") + " qa-pin";
1737
1961
  d.innerHTML = `<span class="d"></span><span></span>`;
@@ -171,15 +171,77 @@ function qaAskAutoArm(card){
171
171
  if(!multi){ fire(); return; } // 全单选:最后一问落定 = 答完,立即发
172
172
  qaAskAutoTimers.set(card, setTimeout(fire, QA_ASK_AUTO_MS));
173
173
  }
174
+ /** 待授权的裁决出口(宿主桥 `window.desk.askApprove`,见 90-glue.js / conversation.js)。
175
+ * 返回 Promise<boolean>;返回 **null** = 这个宿主没有审批桥(调用方回落文本追问)。
176
+ * payload:{id(会话 id), rid(本轮渲染 id), request_id, tool_name, allowed, message}。 */
177
+ function qaAskApproveSend(card, allowed, message){
178
+ const reqId = String((card && card.dataset.requestId) || "");
179
+ if(!reqId) return null;
180
+ const d = (typeof window !== "undefined") ? window.desk : null;
181
+ const extra = (typeof window !== "undefined") ? window.__qaDeskExtra : null;
182
+ const fn = (d && typeof d.askApprove === "function") ? d.askApprove
183
+ : (extra && typeof extra.askApprove === "function") ? extra.askApprove : null;
184
+ if(!fn) return null;
185
+ const ridNum = (card.dataset.rid == null || card.dataset.rid === "") ? NaN : Number(card.dataset.rid);
186
+ const payload = {
187
+ id: card.dataset.sid || "",
188
+ rid: Number.isFinite(ridNum) ? ridNum : null,
189
+ request_id: reqId,
190
+ tool_name: card.dataset.toolName || "",
191
+ allowed: !!allowed,
192
+ message: String(message || ""),
193
+ };
194
+ try{
195
+ return Promise.resolve(fn(payload)).then((r)=> !!(r && (r.ok === undefined ? r : r.ok)), ()=> false);
196
+ }catch(e){
197
+ return Promise.resolve(false);
198
+ }
199
+ }
200
+
174
201
  /** 标记「这一问已答」并把答案发出去。
175
202
  * text = 真正发出去的内容(`【用户选择】…` 信封,见 qaAskText);
176
203
  * display = 卡上「你的回答」那一行显示什么(只有「其他」自由输入需要它:
177
204
  * 信封里的题面不适合当展示文案;不传就不显示这行,选项高亮本身已经是答案)。 */
178
205
  function qaAskMark(card, text, display){
179
206
  const t = String(text || "").trim(); if(!card || !t) return;
207
+ /* 待授权(kind=permission,2026-10-25)的答案不是「一段话」,而是 allowed/denied ——
208
+ 必须走 control 的 ask_answer **原地**写回 CLI 的 control_response
209
+ (见 internal/cli/control.go)。当成 user 消息发进去它不会读,整轮继续挂着。
210
+ 宿主没有审批桥(老壳 / 老服务端)时如实回落成下面的文本追问,不假装送达。 */
211
+ if(card.dataset.askkind === "permission"){
212
+ const labels = [...card.querySelectorAll(".qa-ask-opt.sel")].map(o=>o.dataset.label || "");
213
+ const allowed = labels.indexOf("允许") >= 0;
214
+ const reason = allowed ? "" : String(card.dataset.denyMessage || display || "").trim();
215
+ const sent = qaAskApproveSend(card, allowed, reason);
216
+ if(sent){
217
+ qaAskAutoCancel(card);
218
+ card.dataset.answered = "1";
219
+ const ttl = card.querySelector(".qa-ask-ttl");
220
+ if(ttl) ttl.textContent = allowed ? "审批 · 已允许" : "审批 · 已拒绝";
221
+ const ans = card.querySelector(".qa-ask-ans");
222
+ const shown = String(display == null ? (allowed ? "允许" : "拒绝") : display).trim();
223
+ if(ans && shown){ ans.hidden = false; ans.querySelector(".v").textContent = shown; }
224
+ qaAskSyncSub(card);
225
+ /* ⚠️ 撤卡要等「真的送到」:送不回去(本轮已结束 / 老 core 不认 ask_answer)时
226
+ 把卡退回「待确认」让用户重试,绝不假装批准成功(AGENTS.md 的硬规则)。 */
227
+ sent.then((ok)=>{
228
+ if(!ok){
229
+ delete card.dataset.answered;
230
+ if(ttl) ttl.textContent = "审批 · 待确认";
231
+ qaAskSyncSub(card);
232
+ toast("审批没有送到引擎(这一轮可能已经结束),可以重试或点停止", true);
233
+ return;
234
+ }
235
+ toast(allowed ? "已提交:<b>允许</b>" : "已提交:<b>拒绝</b>", true);
236
+ qaAskDockRetire(card);
237
+ });
238
+ return;
239
+ }
240
+ }
180
241
  qaAskAutoCancel(card); // 已答 → 任何「答满即走」的计时都作废(人工点「提交」也走这里)
181
242
  card.dataset.answered = "1";
182
- const ttl = card.querySelector(".qa-ask-ttl"); if(ttl) ttl.textContent = "提问 · 已回答";
243
+ const ttl = card.querySelector(".qa-ask-ttl");
244
+ if(ttl) ttl.textContent = card.dataset.askkind === "permission" ? "审批 · 已回答" : "提问 · 已回答";
183
245
  const shown = String(display == null ? "" : display).trim();
184
246
  if(shown){
185
247
  const ans = card.querySelector(".qa-ask-ans");
@@ -393,6 +455,18 @@ function qaAskPick(card, optEl){
393
455
  function qaAskFree(card, val){
394
456
  const t = String(val || "").trim();
395
457
  if(!card || !t || qaAskAnswered(card)) return;
458
+ /* 待授权:自由输入 = 拒绝并附上意见(「允许」就走选项,不可能靠打字表达)。
459
+ 把「拒绝」选中,把这段文字当 reason 交给 control 的 ask_answer。 */
460
+ if(card.dataset.askkind === "permission"){
461
+ card.dataset.denyMessage = t;
462
+ const opts = [...card.querySelectorAll(".qa-ask-opt")];
463
+ opts.forEach(o=> o.classList.toggle("sel", (o.dataset.label || "") === "拒绝"));
464
+ qaAskSyncSub(card);
465
+ const qb = card.querySelector(".qa-ask-qb");
466
+ const q = (qb && qb.dataset.q) || "审批";
467
+ qaAskMark(card, "【用户选择】\n- " + q + " → 拒绝:" + t + "\n", t);
468
+ return;
469
+ }
396
470
  const qbs = [...card.querySelectorAll(".qa-ask-qb")];
397
471
  const q = qbs.length === 1 ? (qbs[0].dataset.q || "") : "";
398
472
  qaAskMark(card, "【用户选择】\n" + (q ? "- " + q + " → " + t : "- " + t) + "\n", t);
@@ -7,7 +7,14 @@
7
7
  ask 后到 → 由 qaStreamApplyOne 去重,不会画两张卡)。 */
8
8
  function qaAskFromEvent(ask){
9
9
  if(!ask || typeof ask !== "object") return null;
10
- if(ask.kind && ask.kind !== "question") return null; // 待授权(permission)暂不走提问卡
10
+ /* 待授权(kind=permission,2026-10-25):引擎发的是
11
+ {"kind":"permission","tool_name":"ExitPlanMode","request_id":"perm_…","tool_input":{…}}
12
+ ——**没有 questions**,是一句「要不要放行」。以前这里直接 return null,
13
+ 于是计划审批 / manual 档的工具授权在界面上**完全看不到**(用户报障原话:
14
+ 「没看到审批」,而那一轮其实正卡在 CLI 里等 control_response)。
15
+ 现在合成一张和提问卡同构的两选一(允许 / 拒绝),作答走 control 的 ask_answer。 */
16
+ if(ask.kind === "permission") return [qaAskPermissionQuestion(ask)];
17
+ if(ask.kind && ask.kind !== "question") return null; // 未知 kind:不冒充提问
11
18
  const qs = Array.isArray(ask.questions) ? ask.questions : [];
12
19
  const out = qs.map(q=>({
13
20
  header: String(q.header || ""),
@@ -20,6 +27,31 @@ function qaAskFromEvent(ask){
20
27
  return out.length ? out : null;
21
28
  }
22
29
 
30
+ /* 待授权 → 一张两选一的问题卡(结构沿用提问卡,视觉/交互零分叉)。
31
+ ⚠️ 选项 label 必须是「允许 / 拒绝」这两个字:06-ask-answer 的 qaAskMark 靠它决定
32
+ allowed,换文案(「批准」「同意」)就会被判成拒绝 —— 差别只写在 description 里。 */
33
+ function qaAskPermissionQuestion(ask){
34
+ const tool = String((ask && ask.tool_name) || "");
35
+ if(/exitplanmode/i.test(tool)) return {
36
+ header: "计划审批",
37
+ question: "计划已写好,批准后开始执行?",
38
+ multiSelect: false,
39
+ options: [
40
+ { label: "允许", description: "批准这份计划,模型开始动手" },
41
+ { label: "拒绝", description: "先不执行,让模型继续完善计划" },
42
+ ],
43
+ };
44
+ return {
45
+ header: "工具授权",
46
+ question: "允许执行「" + (tool || "这个工具") + "」吗?",
47
+ multiSelect: false,
48
+ options: [
49
+ { label: "允许", description: "放行这一次调用" },
50
+ { label: "拒绝", description: "不执行,把结果告诉模型" },
51
+ ],
52
+ };
53
+ }
54
+
23
55
  /* ---------- 工具结果在**画布上**的显示上限(2026-10-20)----------
24
56
  用户报「读取历史太慢」。实测一条会话的 404 条 tool_result 共 28.1MB,
25
57
  其中 30 条 >12KB 就占了 27.7MB(最大单条 1.6MB)。这些全量字串既进 `<pre>`,
@@ -108,7 +140,7 @@ function qaStreamApplyOne(ev, ctx){
108
140
  const mdl = String(ev.model || "");
109
141
  const retry = Number(ev.attempt || 0) > 0;
110
142
  if(typeof h.setRunLabel === "function"){
111
- h.setRunLabel(`${eng}${mdl ? " · " + qaModelName(qaModelText(mdl), eng) : ""} · 生成中`);
143
+ h.setRunLabel(`${eng}${mdl ? " · " + qaModelName(qaModelText(mdl, eng), eng) : ""} · 生成中`);
112
144
  }
113
145
  /* 头像右边的「引擎 · 模型」小标同一时机跟成实际值(与胶囊同一条口径:
114
146
  界面说 A、实际跑 B 不行)。机器侧 `.qa-trace`(审查轮 / 数字分身的活动行)
@@ -167,9 +199,12 @@ function qaStreamApplyOne(ev, ctx){
167
199
  会话锚点也不会丢(README「会话 id 闭环」那条要求)。 */
168
200
  if(sid && typeof ctx.onSession === "function") ctx.onSession(sid);
169
201
  const tid = (ev.ask && ev.ask.tool_use_id) ? String(ev.ask.tool_use_id) : "";
170
- /* 同一问会走两条路各报一次(tool_use 事件先到、这条后到)→ 已经画过卡的只**补寻址信息**,
171
- 不再画第二张。补的是卡上的 dataset.sid/rid:快照恢复后照样答得回去(见 askCard)。 */
172
- const prev = tid ? ctx.tools.get(tid) : null;
202
+ const askKind = (ev.ask && ev.ask.kind) ? String(ev.ask.kind) : "question";
203
+ /* 同一问会走两条路各报一次(tool_use 事件先到、这条后到)→ 已经画过卡只**补寻址信息**,
204
+ 不再画第二张。补的是卡上的 dataset.sid/rid:快照恢复后照样答得回去(见 askCard)。
205
+ ⚠️ 只对提问(question)成立:待授权(permission)前面那张工具卡是**普通工具卡**
206
+ (没有 bindAsk),审批必须另画一张 —— 见 qaAskFromEvent 的注释。 */
207
+ const prev = (askKind !== "permission" && tid) ? ctx.tools.get(tid) : null;
173
208
  if(prev && typeof prev.bindAsk === "function"){
174
209
  prev.bindAsk({ sid, rid: ctx.rid });
175
210
  break;
@@ -177,8 +212,14 @@ function qaStreamApplyOne(ev, ctx){
177
212
  const qs = qaAskFromEvent(ev.ask);
178
213
  if(qs && typeof h.askCard === "function"){
179
214
  const tc = h.askCard((ev.ask && ev.ask.tool_name) || "AskUserQuestion", qs,
180
- { title: ev.text || "", sid, rid: ctx.rid });
181
- if(tid) ctx.tools.set(tid, tc);
215
+ { title: ev.text || "", sid, rid: ctx.rid,
216
+ kind: askKind,
217
+ requestId: (ev.ask && ev.ask.request_id) ? String(ev.ask.request_id) : "",
218
+ toolName: (ev.ask && ev.ask.tool_name) ? String(ev.ask.tool_name) : "" });
219
+ /* ⚠️ 待授权**不登记**进 ctx.tools:那个 id 属于原本的 ExitPlanMode 工具卡,
220
+ 工具结果要落到它头上(审批卡只负责「回答」,不参与结果配对);
221
+ 登记进来会把原卡的结果抢走,界面上多一张永远转圈的工具卡。 */
222
+ if(tid && askKind !== "permission") ctx.tools.set(tid, tc);
182
223
  }
183
224
  break;
184
225
  }
@@ -388,6 +429,15 @@ function qaStreamApplyOne(ev, ctx){
388
429
  if(typeof h.setResult === "function") h.setResult(ev.text);
389
430
  /* 台账同源(同上:画面与台账都跟权威正文走,否则历史重画又冒出那句通告) */
390
431
  if(ctx && typeof ctx.onResult === "function") ctx.onResult(ev.text);
432
+ }else if(ev.error_kind){
433
+ /* 「这一轮只有一段通告、没有真正正文」那一档(2026-10-25 用户:「没额度了,设计一个提示卡」):
434
+ core 剥完通告后正文是空串,可流式时那段通告**早就拼进气泡了** —— 不管它,
435
+ 卡片下面会一直铺着「429 You have exceeded…」。这里把已画的正文整段撤掉,
436
+ 让画面只剩额度卡(过程块留着:那是真跑过的)。
437
+ ⚠️ 只在 ev.error_kind 非空时做:老 core 没有这个字段,空正文的 turn_failed
438
+ 仍按原样收尾(不动正文,避免把「引擎真没说完整」的场景擦成白板)。 */
439
+ if(typeof h.clearText === "function") h.clearText();
440
+ if(ctx && typeof ctx.onResult === "function") ctx.onResult("");
391
441
  }
392
442
  if(ev.sessionId || ev.session_id) ctx.onSession(ev.sessionId || ev.session_id);
393
443
  h.endThinking(); finishTools(false);
@@ -407,6 +457,21 @@ function qaStreamApplyOne(ev, ctx){
407
457
  detail: Object.assign({}, ev, { kind: "notice" }), status: ev.status || "" });
408
458
  else if(typeof h.note === "function") h.note(String(ev.text || ev.notice || ""));
409
459
  break;
460
+ /* 主备降级(KindFallback,2026-10-26):这一轮的主引擎失败了,下面的回答是**备用**给的。
461
+ 与 notice 同形态(标记行 + 可展开载荷),但用 sep 图标 + 「备用引擎」字样 ——
462
+ 用户配主备就是为了「额度用尽时还能接着答」,这一行是他确认「生效了」的唯一证据。
463
+ `ev.fallback` 带 from/to 两端(core 的 internal/agent/fallback.go);
464
+ 老 core 不发这条时,这种轮次根本不会降级,不会有缺失。 */
465
+ case "fallback": {
466
+ const fb = ev.fallback || {};
467
+ const to = [fb.to_engine, fb.to_model].filter(Boolean).join(" · ");
468
+ const title = String(ev.text || ("已由备用引擎" + (to ? "「" + to + "」" : "") + "接上"));
469
+ if(typeof h.marker === "function")
470
+ h.marker({ icon: QA_V_ICONS.sep, title,
471
+ detail: Object.assign({}, ev, { kind: "fallback" }), status: "备用引擎" });
472
+ else if(typeof h.note === "function") h.note(title);
473
+ break;
474
+ }
410
475
  /* 团队模式的任务列表(KindTasks,见 internal/team/board.go):一条团队会话里**唯一**那张任务卡。
411
476
  快照语义 —— core 每次任务状态变化都把整份清单重发一次(`text` = {"tasks":[…]}),
412
477
  这里只做「整块换内容」:h.todoList 原地更新同一块面板(见 03-chat-message),