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
@@ -62,6 +62,52 @@ function attErrText(err){
62
62
  /** 缩略图地址:落盘后用真实 URL,落盘途中用本地 objectURL */
63
63
  function attThumbUrl(a){ return a.remoteUrl || a.objectUrl || ""; }
64
64
 
65
+ /* ── 历史附件(2026-10-25 用户报障:「点击历史会话时发送的参考图没了」)──────────
66
+ 根因:core 落盘的附件只有 **path + mime** 两个字段(internal/agent/attachments.go 的
67
+ Attachment),既没有 remoteUrl 也没有 objectUrl —— attThumbUrl 对它恒回空,
68
+ qaRenderMine 里「拿不到地址就跳过」的分支把整排缩略图全吞了。
69
+ 这里给历史那档一条**独立的取址链**,与实时附件互不干扰:
70
+ ① 实时字段(remoteUrl / objectUrl)优先 —— 两档同形时行为不变;
71
+ ② 宿主回读通道(desk.fileUrl / desk.mediaUrl。组件宿主在 installDeskProbe 里把
72
+ mediaUrl 挂成 transport.fileUrl = /desk/media?path=…,按绝对路径回读,上传目录
73
+ 内外的本机媒体文件都通);
74
+ ③ qaShotSrc(file:// 壳内直接拼 file://;产物库路径走内置后端);
75
+ ④ 都没有 → 空串,上层跳过 —— 宁可没有图,也不画一个必然裂的框(与 qaShotSrc
76
+ 预览态的口径一致)。 */
77
+ /** 附件显示名:实时那档有 name;历史附件只有 path → 取 basename,并去掉上传时
78
+ * 服务端加的 <UnixNano>- 前缀,还原成用户上传时的文件名(与组件宿主 attNode 同一口径)。 */
79
+ function attHistName(a){
80
+ const n = a && a.name ? String(a.name) : "";
81
+ if(n) return n;
82
+ const p = String((a && a.path) || "");
83
+ const base = p.split(/[\\/]/).pop() || "";
84
+ return base.replace(/^\d{13,}-/, "") || "截图";
85
+ }
86
+ /** 这条附件是不是图(历史附件只有 mime/path;QA_IMG_EXT 与缩略图同一份后缀表)。 */
87
+ function attHistIsImage(a){
88
+ if(!a) return false;
89
+ const mime = String(a.mime || "");
90
+ if(mime) return /^image\//i.test(mime);
91
+ return QA_IMG_EXT.test(attHistName(a));
92
+ }
93
+ /** 历史附件 → 可显示地址(取址链见上)。实时附件请继续走 attThumbUrl。 */
94
+ function attHistUrl(a){
95
+ if(!a) return "";
96
+ if(a.remoteUrl) return a.remoteUrl;
97
+ if(a.objectUrl) return a.objectUrl;
98
+ const p = String(a.path || "").trim();
99
+ if(!p) return "";
100
+ const via = (name)=>{
101
+ const f = qaDeskFn(name);
102
+ if(typeof f !== "function") return "";
103
+ try{ return f(p) || ""; }catch(e){ return ""; }
104
+ };
105
+ const u = via("fileUrl") || via("mediaUrl");
106
+ if(u) return u;
107
+ if(typeof qaShotSrc === "function"){ try{ return qaShotSrc(p) || ""; }catch(e){} }
108
+ return "";
109
+ }
110
+
65
111
  /** 落盘返回的 url → 浏览器可直接用的绝对地址。
66
112
  * 壳内与预览态都拿得到 prdBackendBase(同一个内置后端);
67
113
  * file:// 直开且后端未就绪时返回空 —— 那种情况继续用 objectURL,
@@ -393,6 +439,9 @@ function qaArtKind(p){
393
439
  if(QA_IMG_EXT.test(s)) return "image";
394
440
  if(/\.(mp4|mov|m4v|webm|mkv|avi)$/i.test(s)) return "video";
395
441
  if(/\.(mp3|wav|m4a|aac|ogg|flac)$/i.test(s)) return "audio";
442
+ if(/\.(?:html?|xhtml)$/i.test(s)) return "html"; // HTML 产物:产物卡里嵌 iframe 预览
443
+ //(是否「自包含可预览」由 qaHtmlStandalone
444
+ // 按内容判,见 qaArtMount 的换卡逻辑)
396
445
  if(QA_ART_EXT.test(s)) return "file";
397
446
  return "";
398
447
  }
@@ -608,10 +657,34 @@ function qaArtCollect(text){
608
657
  return out.slice(0, 12); // 一次最多画 12 张(多了就不是「产物」了)
609
658
  }
610
659
 
611
- /** 一张产物卡(按类型分流:图 / 视频 / 音频 / 其它文件)。 */
612
- function qaArtNode(it){
613
- const kind = qaArtKind(it.abs) || "file";
614
- const name = String((it.abs || "").split("/").pop() || "文件");
660
+ /** HTML 产物是否「自包含、可独立预览」(2026-10-24 用户:「index.html这种非静态页面要排除
661
+ * 不需要预览」「按文件名区分不行」→ 改**内容**判据):
662
+ * 读文件头(`/desk/path-read`,512KB 上限)扫 `<script src>` / `<link href>` ——
663
+ * 引用了**外部 js / css**(相对 / 绝对路径,非 data: / 远端)的就是应用入口或页面壳,
664
+ * 单塞一个 iframe 里预览出来是白板或半个页面 → 返 false,退回普通文件卡。
665
+ * ⚠️ 通道拿不到 / 读失败 / 太大读不了 → 返 true(宁可照画,不凭猜把卡降级)。 */
666
+ function qaHtmlStandalone(p){
667
+ const rd = qaDeskFn("pathRead");
668
+ if(!rd) return Promise.resolve(true);
669
+ return Promise.resolve(rd({ path: String(p), base: qaArtBase() })).then((r)=>{
670
+ if(!r || !r.ok || typeof r.text !== "string") return false; // tooBig / 读不到:多半是应用入口
671
+ const t = r.text.slice(0, 65536).toLowerCase();
672
+ const re = /(?:src|href)\s*=\s*["']([^"']+)["']/g;
673
+ let m;
674
+ while((m = re.exec(t))){
675
+ const u = m[1].trim();
676
+ if(!u || /^(?:data:|https?:|\/\/|#|mailto:)/i.test(u)) continue;
677
+ if(/\.(?:m?js|css)(?:[?#]|$)/i.test(u)) return false; // 引了外部脚本 / 样式 → 不自包含
678
+ }
679
+ return true;
680
+ }).catch(()=> true);
681
+ }
682
+
683
+ /** 一张产物卡(按类型分流:图 / 视频 / 音频 / HTML / 其它文件)。
684
+ * `force`(2026-10-24)= 强制按这一档画(HTML 换卡逻辑用:入口页降级成普通文件卡)。 */
685
+ function qaArtNode(it, force){
686
+ const kind = force || qaArtKind(it.abs) || "file";
687
+ const name = String((it.abs || "").split(/[\\/]/).pop() || "文件");
615
688
  const card = document.createElement("div");
616
689
  card.className = "qa-art qa-art-" + kind;
617
690
  card.dataset.path = it.abs || "";
@@ -627,6 +700,15 @@ function qaArtNode(it){
627
700
  if(kind === "video") m.playsInline = true;
628
701
  card.appendChild(m);
629
702
  }
703
+ if(kind === "html"){ // HTML:iframe 就地预览(2026-10-24 用户:
704
+ const f = document.createElement("iframe"); // 「会话产物如果有html 现在没显示预览效果」)
705
+ f.src = qaArtUrl(it.abs);
706
+ f.title = name;
707
+ f.setAttribute("sandbox", "allow-scripts allow-same-origin allow-popups allow-forms");
708
+ f.setAttribute("loading", "lazy");
709
+ f.setAttribute("draggable", "false");
710
+ card.appendChild(f);
711
+ }
630
712
  const cap = document.createElement("div");
631
713
  cap.className = "qa-art-cap";
632
714
  if(kind === "file"){ // 其它文件:后缀徽标 + 名字 + 大小
@@ -726,7 +808,7 @@ function qaArtMount(host, paths, opts){
726
808
  if(!buckets.has(k)) buckets.set(k, []);
727
809
  buckets.get(k).push(x);
728
810
  });
729
- ["image", "video", "audio", "file"].forEach((k)=>{
811
+ ["image", "video", "audio", "html", "file"].forEach((k)=>{
730
812
  const arr = buckets.get(k);
731
813
  if(!arr || !arr.length) return;
732
814
  const row = document.createElement("div");
@@ -734,6 +816,22 @@ function qaArtMount(host, paths, opts){
734
816
  arr.forEach((x)=> row.appendChild(qaArtNode(x)));
735
817
  host.appendChild(row);
736
818
  });
819
+ /* HTML 产物卡是**异步定形**的:先按 html 画(iframe 预览),随后读文件头判「自包含」,
820
+ 引用了外部 js / css 的(index.html 这类应用入口)降级成普通文件卡 —— 内容判据,
821
+ 不按文件名(2026-10-24 用户:「index.html这种非静态页面要排除」「按文件名区分不行」)。 */
822
+ hit.forEach((x)=>{
823
+ if(qaArtKind(x.abs) !== "html") return;
824
+ qaHtmlStandalone(x.abs).then((ok)=>{
825
+ if(ok) return;
826
+ const cards = host.querySelectorAll(".qa-art-html");
827
+ for(const c of cards){
828
+ if(qaArtCanon(c.dataset.path) === qaArtCanon(x.abs)){
829
+ c.parentNode.replaceChild(qaArtNode(x, "file"), c);
830
+ break;
831
+ }
832
+ }
833
+ }).catch(()=>{ /* 换不了就保持原样 */ });
834
+ });
737
835
  return hit.length;
738
836
  }).catch(()=> 0); // 探针失败 = 当作「都不是产物」,正文照旧
739
837
  }
@@ -803,6 +901,59 @@ function qaArtDrop(card){
803
901
  }, true);
804
902
  })();
805
903
 
904
+ /* ── HTML 产物 · 点击打开(2026-10-24 用户:「预览是缩略效果了 要支持点击查看」
905
+ → 「现在有内置浏览器啊 点击跳转打开啊」)────────────────────────────────
906
+ 点卡片:宿主有内置浏览器(`MagicHost.openBrowser`,移动端已挂)就**跳内置浏览器**;
907
+ 没有的宿主退回**内核自画的全屏预览层**(不依赖宿主能力,PC / magic-test 通吃),
908
+ Esc / 关闭按钮退出。
909
+ ⚠️ 缩略 iframe 设 `pointer-events:none`(CSS):iframe 内部的点击不会冒泡到卡片,
910
+ 不设的话「点卡片」只有边条能点中;代价是缩略态不能滚里面内容 —— 反正要看就点开。
911
+ ⚠️ 页面级一次性挂载,与上面 error 收尾同一个形制。 */
912
+ (function(){
913
+ if(typeof document === "undefined" || window.__qaArtHtmlPeek) return;
914
+ window.__qaArtHtmlPeek = true;
915
+ let layer = null;
916
+ const close = ()=>{
917
+ if(layer){ layer.parentNode && layer.parentNode.removeChild(layer); layer = null; }
918
+ document.removeEventListener("keydown", onKey);
919
+ };
920
+ function onKey(e){ if(e && e.key === "Escape") close(); }
921
+ function openPeek(p, u){
922
+ close();
923
+ layer = document.createElement("div");
924
+ layer.className = "qa-art-html-peek";
925
+ const bar = document.createElement("div");
926
+ bar.className = "qa-art-html-peek-bar";
927
+ const t = document.createElement("span");
928
+ t.className = "qa-art-html-peek-t";
929
+ t.textContent = String(p).split(/[\\/]/).pop() || p;
930
+ const x = document.createElement("button");
931
+ x.type = "button"; x.className = "qa-art-html-peek-x"; x.textContent = "关闭";
932
+ x.addEventListener("click", close);
933
+ bar.appendChild(t); bar.appendChild(x);
934
+ const f = document.createElement("iframe");
935
+ f.src = u; f.title = t.textContent;
936
+ f.setAttribute("sandbox", "allow-scripts allow-same-origin allow-popups allow-forms");
937
+ layer.appendChild(bar); layer.appendChild(f);
938
+ document.body.appendChild(layer);
939
+ document.addEventListener("keydown", onKey);
940
+ }
941
+ document.addEventListener("click", function(e){
942
+ const card = e.target && e.target.closest ? e.target.closest(".qa-art-html") : null;
943
+ if(!card || layer) return;
944
+ const p = String(card.dataset.path || "").trim();
945
+ if(!p || typeof qaArtUrl !== "function") return;
946
+ const u = qaArtUrl(p);
947
+ if(!u) return; // 拼不出地址(预览态)→ 不动
948
+ e.preventDefault();
949
+ try{
950
+ const ob = (typeof window !== "undefined" && window.MagicHost) ? window.MagicHost.openBrowser : null;
951
+ if(typeof ob === "function"){ ob(u); return; } // 有内置浏览器 → 跳浏览器
952
+ }catch(err){ /* 宿主通道不通 → 退全屏层 */ }
953
+ openPeek(p, u);
954
+ }, false);
955
+ })();
956
+
806
957
  /* ── 会话产物 · 消息末尾的「本轮产物」(2026-10-15)─────────────────────────
807
958
  用户原话:「会话有新的图片、视频文件产生 应该在消息结束时附加在末尾作为会话产物显示预览」。
808
959
  正文里写出来的产物路径早就有渲染(qaShotsMount / qaArtMount:图变缩略图、视频变就地播放器),
@@ -819,7 +970,7 @@ function qaArtDrop(card){
819
970
  ⚠️ 与正文里已经画过的**不重复**(按绝对路径与文件名双判):用户要的是「正文没提的也看得见」,
820
971
  正文里已经有的再画一遍就成了同一张图出现两次。
821
972
  ⚠️ 只**收**不摘、只**补**不改:正文与工具输出原文一个字都不动。 */
822
- const QA_TURN_ART_KIND = /^(?:image|video|file)$/;
973
+ const QA_TURN_ART_KIND = /^(?:image|video|file|html)$/;
823
974
  /** 记一条候选路径进**这一轮**的产物集(去重;留图 / 视频 / 文件三档)。 */
824
975
  function qaTurnArtsNote(run, p){
825
976
  const s = String(p == null ? "" : p).trim();
@@ -313,7 +313,10 @@ function qaSessionPlayer(){
313
313
  }catch(e){ /* 收尾失败不影响回放 */ }
314
314
  h = null;
315
315
  }else if(arts){ arts.flush(null); } // 没有答复卡的一轮:清掉残留,别串到下一轮
316
- qaRenderMine(String(ev.text || ""), [], { hosted: !!ev.hosted });
316
+ /* 历史附件(参考图)随 user 事件落盘({path, mime}):透传给内核的 attHistUrl
317
+ 按路径换回读地址 —— 与 93-rail 同一口径(2026-10-25 用户报障修复)。 */
318
+ qaRenderMine(String(ev.text || ""),
319
+ Array.isArray(ev.attachments) ? ev.attachments : [], { hosted: !!ev.hosted });
317
320
  t0 = atMs(ev);
318
321
  continue;
319
322
  }
@@ -349,7 +352,7 @@ function qaSessionPlayer(){
349
352
  qaRenderDone(!failed && !stopped,
350
353
  failed ? "调用失败 · " + String(ev.reason || ev.error || "未知错误").slice(0, 200)
351
354
  : stopped ? "已停止" : "已完成",
352
- null, turnMs || null);
355
+ null, turnMs || null, failed ? ev : null);
353
356
  if(arts) arts.flush(h); // 终态之后这一轮的产物补在消息末尾(与实时 onDone 同位)
354
357
  t0 = 0; // 这一轮收尾了:下一轮(若没有 user 事件)重新起算
355
358
  /* ⚠️ 句柄必须一起置空(2026-10-21 用户:「同一轮对话 显示成了两个」):
@@ -255,6 +255,223 @@ function qaMarkerNode(opts){
255
255
  return wrap;
256
256
  }
257
257
 
258
+ /* ---------- 错误显示卡(终态失败的统一落笔)----------
259
+ 用户 2026-10-23:「没有设计错误显示卡 现在是普通的 json 文本」—— 失败那一轮原来只落一行
260
+ `.qa-done.err` 等宽灰字(「调用失败 · <reason>」),没有卡片形态。这里补一张**真正的卡**:
261
+ 朱砂描边 + 淡红底的卡片面,抬头是告警图标 + 「调用失败」标题 + 「失败」状态徽章,
262
+ 主体是**根因**(reason,没被截断,人话那一句),底部可展开「错误详情」(完整错误链 /
263
+ JSON,走 qaCodeFrameNode,可复制)。
264
+
265
+ ⚠️ 与标记行(qaMarkerNode)的分工:control_error / command / notice 那种**过程性**的失败
266
+ 仍落标记行(它们不是这一轮的终态,只是过程里的一次失败通知);这张卡只给**这一轮真的
267
+ 以失败收场**的终态(error / turn_failed / 桥不可用 / 回放失败)。两条形态不重叠。
268
+ ⚠️ 快照重建安全:整张卡的状态(开合)都靠 class / data-*,点击走 #qa-stream 事件委托
269
+ (见 07-stream-delegation 的 `.qa-mk-row` 分支)—— 展开详情那块直接复用 `.qa-mk`
270
+ 那套词汇,所以委托不用为这张卡单独加逻辑,快照恢复出来的卡照样能点开。
271
+ ⚠️ 根节点**保留 `.qa-done` 类**:收尾行契约(`tests/check-conv-shared-host.mjs` 数 `.qa-done`)
272
+ 只认这个类,改类名会让那条断言失守。卡片形态靠 `.qa-errcard` 额外类实现。 */
273
+ function qaErrorCardNode(opts){
274
+ const o = opts || {};
275
+ const t = o.elapsedMs || "";
276
+ const det = o.detail;
277
+ let reason = "";
278
+ let chain = null;
279
+ /* 优先从结构化错误信封取根因与完整链(reason 是最内层原因、error 是带引擎包装的整条链);
280
+ 没有信封(桥异常 / 抛错)就退回 label 里那段,再不行给「未知错误」。 */
281
+ if(det && typeof det === "object"){
282
+ reason = String(det.reason || "").trim();
283
+ chain = det.error != null && String(det.error).trim() ? det.error
284
+ : det.message != null && String(det.message).trim() ? det.message
285
+ : null;
286
+ }
287
+ if(!reason && typeof o.label === "string"){
288
+ reason = String(o.label)
289
+ .replace(/^调用失败\s*·\s*/, "")
290
+ .replace(/\s*·\s*总耗时[\s\S]*$/, "")
291
+ .trim();
292
+ }
293
+ if(!reason) reason = "未知错误";
294
+
295
+ const wrap = document.createElement("div");
296
+ wrap.className = "qa-done err qa-errcard";
297
+
298
+ const hd = document.createElement("div");
299
+ hd.className = "qa-ec-hd";
300
+ hd.innerHTML =
301
+ '<span class="qa-ec-ico">' + (QA_V_ICONS.alert || "") + '</span>'
302
+ + '<span class="qa-ec-tt">调用失败</span>'
303
+ + qaStatusBadgeHtml("failed");
304
+ wrap.appendChild(hd);
305
+
306
+ const bd = document.createElement("div");
307
+ bd.className = "qa-ec-bd";
308
+ const rp = document.createElement("p");
309
+ rp.className = "qa-ec-reason";
310
+ rp.textContent = reason;
311
+ bd.appendChild(rp);
312
+ if(t){
313
+ const mt = document.createElement("div");
314
+ mt.className = "qa-ec-meta";
315
+ mt.textContent = "总耗时 " + t;
316
+ bd.appendChild(mt);
317
+ }
318
+ /* 完整错误链:复用 `.qa-mk` 词汇(行 + 明细),点击开合走委托,快照安全 */
319
+ if(chain != null && String(chain).trim()){
320
+ const mk = document.createElement("div");
321
+ mk.className = "qa-mk";
322
+ const row = document.createElement("div");
323
+ row.className = "qa-mk-row";
324
+ row.dataset.tog = "1";
325
+ row.innerHTML = '<span class="qa-mk-chev">' + (QA_V_ICONS.chev || "") + '</span>'
326
+ + '<span class="qa-mk-tt">错误详情</span>';
327
+ mk.appendChild(row);
328
+ const mkbd = document.createElement("div");
329
+ mkbd.className = "qa-mk-bd";
330
+ const isStr = typeof chain === "string";
331
+ mkbd.appendChild(qaCodeFrameNode("error", isStr ? String(chain) : JSON.stringify(chain, null, 2)));
332
+ mk.appendChild(mkbd);
333
+ bd.appendChild(mk);
334
+ }
335
+ wrap.appendChild(bd);
336
+ return wrap;
337
+ }
338
+
339
+ /* ---------- 额度 / 限流提示卡(2026-10-25)----------
340
+ 用户报障原话:「没额度了,设计一个提示卡」—— 截图里那一轮引擎把供应商的 429 通告
341
+ **当正文**发了回来,气泡里铺着一大段英文原文,上下还挂着「任务耗时 1m 26s」与复制/重试
342
+ 图标行,整体观感像「这一轮正常完成了」。这比普通报错糟得多:用户既看不出没跑成,
343
+ 也不知道该等还是该换模型。
344
+
345
+ ⚠️ **界面不解析通告原文**(判据只在 core:`internal/agent/limitnotice.go` 分类 + 取恢复时刻,
346
+ 事件上带 `error_kind` / `error_code` / `reset_at`)。这里只做展示:认不出 kind 就退回
347
+ 普通失败卡 —— 把「工具报错」画成「额度不足」比不画更糟。
348
+ ⚠️ 与 qaErrorCardNode 的关系:同一骨架(`.qa-errcard` / `.qa-ec-hd` / `.qa-ec-bd`),
349
+ 只换色与信息层次(加一行「什么时候恢复」+ 一行建议)。所以 `.qa-done` 收尾行契约、
350
+ `.qa-mk` 展开委托两条都照旧成立,不必为这张卡单独加逻辑。 */
351
+ const QA_QUOTA_KINDS = {
352
+ quota: {
353
+ title: "额度用尽", badge: "额度不足",
354
+ lead: "引擎账号的额度已用尽,这一轮没有跑完。",
355
+ hint: "建议:等额度恢复后再试,或切换到其它模型继续。",
356
+ },
357
+ rate_limit: {
358
+ title: "请求太频繁", badge: "已被限流",
359
+ lead: "请求过于频繁,引擎暂时拒绝了这一轮。",
360
+ hint: "建议:隔几分钟再试;这不是配置问题,不必改设置。",
361
+ },
362
+ };
363
+
364
+ /* 恢复时刻的展示文案:{clock:"15:07", rel:"约 2 小时后", past:false};取不到回 null。
365
+ ⚠️ 只用 `new Date()` 解 core 给的 **RFC3339**(带偏移的确定形状),不做宽松解析 ——
366
+ V8 会把 `CST` 当美国中部时间(偏 14 小时,实测过),所以含糊的拼法在 core 侧就挡掉了。 */
367
+ function qaQuotaReset(at){
368
+ const s = String(at || "").trim();
369
+ if(!s) return null;
370
+ const t = new Date(s);
371
+ if(isNaN(t.getTime())) return null;
372
+ const now = new Date();
373
+ const pad = (n)=> (n < 10 ? "0" : "") + n;
374
+ const sameDay = t.getFullYear() === now.getFullYear() && t.getMonth() === now.getMonth()
375
+ && t.getDate() === now.getDate();
376
+ const clock = (sameDay ? "" : (t.getMonth() + 1) + "月" + t.getDate() + "日 ")
377
+ + pad(t.getHours()) + ":" + pad(t.getMinutes());
378
+ const diff = t.getTime() - now.getTime();
379
+ if(diff <= 0) return { clock: clock, rel: "", past: true };
380
+ /* 相对时间只做「大致」参考,**不跨档乱除**:超过一天就说天(额度按 5 小时一档重置,
381
+ 真通告基本都落在「几分钟 / 几小时」这一档;算出「约 639910 小时后」是捅了马蜂窝的写法)。 */
382
+ const min = Math.round(diff / 60000);
383
+ const rel = min < 1 ? "不到 1 分钟"
384
+ : min < 60 ? "约 " + min + " 分钟后"
385
+ : min < 60 * 24 ? "约 " + Math.round(min / 60) + " 小时后"
386
+ : "约 " + Math.round(min / 60 / 24) + " 天后";
387
+ return { clock: clock, rel: rel, past: false };
388
+ }
389
+
390
+ /* 事件信封 → 额度卡要的全部文案;**不是**额度/限流就回 null(调用方退回普通失败卡)。
391
+ 纯函数(不碰 DOM):单测与「这张卡该不该出现」的判定都只看它。 */
392
+ function qaQuotaInfo(det){
393
+ if(!det || typeof det !== "object") return null;
394
+ const kind = String(det.error_kind || "").trim();
395
+ const k = QA_QUOTA_KINDS[kind];
396
+ if(!k) return null;
397
+ return {
398
+ kind: kind, code: String(det.error_code || "").trim(),
399
+ raw: String(det.reason || det.error || "").trim(),
400
+ title: k.title, badge: k.badge, lead: k.lead, hint: k.hint,
401
+ reset: qaQuotaReset(det.reset_at),
402
+ };
403
+ }
404
+
405
+ function qaQuotaCardNode(opts){
406
+ const o = opts || {};
407
+ const q = o.info || qaQuotaInfo(o.detail);
408
+ if(!q) return qaErrorCardNode(opts); // 兜底:形状一样,只是不出额度卡
409
+
410
+ const wrap = document.createElement("div");
411
+ wrap.className = "qa-done err qa-errcard qa-quota";
412
+
413
+ const hd = document.createElement("div");
414
+ hd.className = "qa-ec-hd";
415
+ hd.innerHTML =
416
+ '<span class="qa-ec-ico">' + (QA_V_ICONS.alert || "") + '</span>'
417
+ + '<span class="qa-ec-tt"></span>'
418
+ + '<span class="qa-qc-badge"></span>';
419
+ hd.querySelector(".qa-ec-tt").textContent = q.title;
420
+ hd.querySelector(".qa-qc-badge").textContent = q.badge;
421
+ wrap.appendChild(hd);
422
+
423
+ const bd = document.createElement("div");
424
+ bd.className = "qa-ec-bd";
425
+ const lead = document.createElement("p");
426
+ lead.className = "qa-ec-reason";
427
+ lead.textContent = q.lead;
428
+ bd.appendChild(lead);
429
+
430
+ /* 恢复时刻:时刻本身加粗(用户此刻只想知道「几点能再用」),相对时间跟在后面;
431
+ 已经过了恢复时刻就说「可以重试」—— 这时候再写「将于…恢复」就是假话。 */
432
+ if(q.reset){
433
+ const rp = document.createElement("p");
434
+ rp.className = "qa-qc-reset";
435
+ const b = document.createElement("b");
436
+ b.textContent = q.reset.clock;
437
+ if(q.reset.past){
438
+ rp.appendChild(document.createTextNode("已过恢复时刻("));
439
+ rp.appendChild(b);
440
+ rp.appendChild(document.createTextNode("),可以重试。"));
441
+ }else{
442
+ rp.appendChild(document.createTextNode("将于 "));
443
+ rp.appendChild(b);
444
+ rp.appendChild(document.createTextNode(" 恢复" + (q.reset.rel ? "(" + q.reset.rel + ")" : "")));
445
+ }
446
+ bd.appendChild(rp);
447
+ }
448
+
449
+ const hint = document.createElement("p");
450
+ hint.className = "qa-qc-hint";
451
+ hint.textContent = q.hint;
452
+ bd.appendChild(hint);
453
+
454
+ /* 原文:留一条可展开的明细(排障要它 —— 谁限流的、request id 是多少)。
455
+ 复用 `.qa-mk` 词汇 → 点击走 #qa-stream 委托,快照重建后照样能展开。 */
456
+ if(q.raw){
457
+ const mk = document.createElement("div");
458
+ mk.className = "qa-mk";
459
+ const row = document.createElement("div");
460
+ row.className = "qa-mk-row";
461
+ row.dataset.tog = "1";
462
+ row.innerHTML = '<span class="qa-mk-chev">' + (QA_V_ICONS.chev || "") + '</span>'
463
+ + '<span class="qa-mk-tt">原文</span>';
464
+ mk.appendChild(row);
465
+ const mkbd = document.createElement("div");
466
+ mkbd.className = "qa-mk-bd";
467
+ mkbd.appendChild(qaCodeFrameNode(q.code ? "HTTP " + q.code : "原文", q.raw));
468
+ mk.appendChild(mkbd);
469
+ bd.appendChild(mk);
470
+ }
471
+ wrap.appendChild(bd);
472
+ return wrap;
473
+ }
474
+
258
475
  /* ---------- ④ 分隔条(anywhere Marker variant="separator")----------
259
476
  用在「横切整条会话」的事件上 —— anywhere 那边是**会话压缩**(`MarkerContentKind="compact"`,
260
477
  进行中/已完成两种文案)。分隔条不是谁说的话,形状本身就在说「这里是一条分界」。