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.
- package/CLIENT-CONTRACT.md +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +40 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +69 -10
- package/cmd/magic-client/main.go +210 -32
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +168 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +6 -3
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- 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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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">${
|
|
1215
|
-
?
|
|
1216
|
-
:
|
|
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
|
-
|
|
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");
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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),
|