magic-agent 2.4.2 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +290 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -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 +37 -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 +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -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 +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -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 +33 -9
- package/cmd/magic-client/main.go +57 -26
- 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_resume.go +13 -2
- 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/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- 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 +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- 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 +160 -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/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +7 -10
- 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/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/daemon.go +90 -17
- 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/jobs.go +294 -21
- 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 +193 -7
- package/internal/client/lan_test.go +110 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -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/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/session.go +92 -0
- package/internal/session/sessionlog.go +23 -0
- package/internal/session/sessionlog_read_test.go +39 -0
- 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 +1 -5
- 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/package.json +8 -2
|
@@ -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,86 @@ 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
|
+
② 官方模型在前、自定义模型(有 model_vendors 的)整体在后;
|
|
473
|
+
③ 组内按积分倍率升序(便宜的在前),没有倍率的排在组尾;同倍率保持原顺序。 */
|
|
474
|
+
function qaModelsOrdered(engines, engine, models){
|
|
475
|
+
const list = Array.isArray(models) ? models.slice() : [];
|
|
476
|
+
if(list.length < 2) return list;
|
|
477
|
+
const idx = new Map();
|
|
478
|
+
list.forEach(function(m, i){ idx.set(m, i); });
|
|
479
|
+
const creditOf = function(id){
|
|
480
|
+
const raw = qaModelCredit(engines, engine, id).replace(/^×/, "");
|
|
481
|
+
const n = parseFloat(raw);
|
|
482
|
+
return isNaN(n) ? Infinity : n;
|
|
483
|
+
};
|
|
484
|
+
list.sort(function(a, b){
|
|
485
|
+
if(a === "auto") return -1;
|
|
486
|
+
if(b === "auto") return 1;
|
|
487
|
+
const ca = !!qaModelVendor(engines, engine, a);
|
|
488
|
+
const cb = !!qaModelVendor(engines, engine, b);
|
|
489
|
+
if(ca !== cb) return ca ? 1 : -1;
|
|
490
|
+
const x = creditOf(a), y = creditOf(b);
|
|
491
|
+
if(x !== y){
|
|
492
|
+
if(x === Infinity) return 1;
|
|
493
|
+
if(y === Infinity) return -1;
|
|
494
|
+
return x - y;
|
|
495
|
+
}
|
|
496
|
+
return idx.get(a) - idx.get(b);
|
|
497
|
+
});
|
|
498
|
+
return list;
|
|
499
|
+
}
|
|
500
|
+
/* 模型 → **套餐额度快照**(如 `"5h 98% · 周 53%"`);没有数据 → `""`。
|
|
501
|
+
数据来自 `--engines` 的 `model_quota`(model id → `{interval_left, weekly_left}`,
|
|
502
|
+
键与 `models[]` 同形)+ `credit_suffix`(单位,缺省 `%`)。目前只有 llm 的 MiniMax
|
|
503
|
+
模型有:同一账号一份快照,core 把它挂到每个走该端点的模型上;别的供应商模型没有。
|
|
504
|
+
PC 宿主的 glue 可能把字段归一成 camelCase,两种拼写这里都认。 */
|
|
505
|
+
function qaModelQuota(engines, engine, id){
|
|
506
|
+
const key = qaModelText(id);
|
|
507
|
+
if(!key) return "";
|
|
508
|
+
const list = Array.isArray(engines) ? engines : [];
|
|
509
|
+
for(const e of list){
|
|
510
|
+
if(!e || (engine && e.engine !== engine)) continue;
|
|
511
|
+
const map = e.model_quota || e.modelQuota || null;
|
|
512
|
+
if(!map || typeof map !== "object") continue;
|
|
513
|
+
const q = map[key];
|
|
514
|
+
if(!q || typeof q !== "object") continue;
|
|
515
|
+
const read = (a, b)=>String((a != null ? a : (b == null ? "" : b))).trim();
|
|
516
|
+
const iv = read(q.interval_left, q.intervalLeft);
|
|
517
|
+
const wk = read(q.weekly_left, q.weeklyLeft);
|
|
518
|
+
if(!iv && !wk) continue;
|
|
519
|
+
const unit = e.credit_suffix || e.creditSuffix || "%";
|
|
520
|
+
const parts = [];
|
|
521
|
+
if(iv) parts.push("5h " + iv + unit);
|
|
522
|
+
if(wk) parts.push("周 " + wk + unit);
|
|
523
|
+
return parts.join(" · ");
|
|
524
|
+
}
|
|
525
|
+
return "";
|
|
526
|
+
}
|
|
380
527
|
/* **引擎名小标**(2026-09-22 用户:「头像旁边显示引擎名字」;同日追加「名字显示的大一点
|
|
381
528
|
再加上模型显示」→ 文案 = 「引擎 · 模型」,字号见 app.css M8):左侧消息头像独占一行(见文末 M7)
|
|
382
529
|
之后,头像那一行右侧空着 —— 把「这一条是哪个引擎、哪个模型说的」挂在头像右边。
|
|
@@ -394,7 +541,7 @@ function qaWhoLabel(engine, model, modelOnly){
|
|
|
394
541
|
const nm = String(engine == null ? "" : engine).trim();
|
|
395
542
|
if(!nm) return "";
|
|
396
543
|
const md = String(model == null ? "" : model).trim();
|
|
397
|
-
const mName = md ? qaModelName(qaModelText(md), nm) : "";
|
|
544
|
+
const mName = md ? qaModelName(qaModelText(md, nm), nm) : "";
|
|
398
545
|
if(modelOnly) return mName || nm;
|
|
399
546
|
return mName ? `${nm} · ${mName}` : nm;
|
|
400
547
|
}
|
|
@@ -505,9 +652,16 @@ function qaRenderMine(text, atts, opts){
|
|
|
505
652
|
const row = document.createElement("div");
|
|
506
653
|
row.className = "qa-atts";
|
|
507
654
|
atts.forEach((a)=>{
|
|
508
|
-
|
|
655
|
+
/* 实时附件(remoteUrl/objectUrl 在)走 attThumbUrl,行为与以前逐像素一致;
|
|
656
|
+
历史附件只有 {path, mime} —— 走 attHistUrl / attHistName 这条取址链
|
|
657
|
+
(见 19-attachments 的「历史附件」段),否则点开历史会话参考图整排消失。 */
|
|
658
|
+
const live = !!(a && (a.remoteUrl || a.objectUrl));
|
|
659
|
+
const u = live ? attThumbUrl(a) : attHistUrl(a);
|
|
509
660
|
if(!u) return;
|
|
510
|
-
|
|
661
|
+
if(!live && !attHistIsImage(a)) return; // 历史档的非图附件没有缩略形态,不画破图
|
|
662
|
+
const nm = attHistName(a);
|
|
663
|
+
const sz = Number(a && a.size) > 0 ? " · " + attSize(Number(a.size)) : "";
|
|
664
|
+
row.appendChild(qaThumbNode(u, nm + sz, nm));
|
|
511
665
|
});
|
|
512
666
|
if(row.children.length) host.appendChild(row);
|
|
513
667
|
}
|
|
@@ -598,6 +752,8 @@ function qaRenderAnswer(host){
|
|
|
598
752
|
否则这一轮里后起的机器块会落错地方(旧实现预置了空的思考块/工具组,恰好掩盖了这点)。 */
|
|
599
753
|
const machineHost = ()=> (div.__traceWrap && div.__traceWrap.querySelector(".qa-bd")) || bd;
|
|
600
754
|
let curThink = null, curGroup = null, curText = null; // 当前「打开」的三类块
|
|
755
|
+
/* 本回合**最后一次**建出的思考块(含已定格的那些)——「交替推理」要往里续,见 ensureThink。 */
|
|
756
|
+
let lastThink = null;
|
|
601
757
|
/* 本条消息里**建过的所有**工具组(不只当前那个)——收尾兜底要能封到「被 endToolRun 摘掉
|
|
602
758
|
引用、之后又被卡片落定重新点亮」的那种组(见 settleGroups 的说明)。 */
|
|
603
759
|
const groups = [];
|
|
@@ -727,7 +883,15 @@ function qaRenderAnswer(host){
|
|
|
727
883
|
(2026-09-16 用户报障:WebSearch 点击无法展开)。用户手动开合过 → 记 data-touched。 */
|
|
728
884
|
function ensureThink(){
|
|
729
885
|
if(curThink) return curThink;
|
|
730
|
-
|
|
886
|
+
/* ⚠️ **交替推理不能封正文**(2026-10-25 用户报障「乱显示了」):
|
|
887
|
+
codebuddy + glm-5.3-flash 实测把思考与正文**逐字交替**吐(thinking → text →
|
|
888
|
+
thinking → text…,同一 message id、见会话日志)。原实现无条件 `sealText()`,
|
|
889
|
+
于是每个几字的 text 增量都被封成一块 → 画布上就是
|
|
890
|
+
「半句话 / 任务耗时 1 项过程 / 半句话」一路铺下来。
|
|
891
|
+
正文正在进行时思考只**旁挂**(思考块为收起态,正文继续在同一块里追加)。 */
|
|
892
|
+
if(!curText){
|
|
893
|
+
endToolRun(); sealText(); // 换块:正文与这一段工具到此为止
|
|
894
|
+
}
|
|
731
895
|
const el = document.createElement("div");
|
|
732
896
|
el.className = "qa-think";
|
|
733
897
|
el.hidden = true;
|
|
@@ -736,6 +900,7 @@ function qaRenderAnswer(host){
|
|
|
736
900
|
curThink = { el, tt: el.querySelector(".tt"), dur: el.querySelector(".qa-think-dur"),
|
|
737
901
|
body: el.querySelector(".qa-think-bd"), start: 0, timer: null,
|
|
738
902
|
touched: ()=> el.dataset.touched === "1" };
|
|
903
|
+
lastThink = curThink;
|
|
739
904
|
return curThink;
|
|
740
905
|
}
|
|
741
906
|
function thinkBegin(c){
|
|
@@ -865,7 +1030,7 @@ function qaRenderAnswer(host){
|
|
|
865
1030
|
|
|
866
1031
|
const run = document.createElement("div");
|
|
867
1032
|
run.className = "qa-run";
|
|
868
|
-
run.innerHTML = qaRunPillHtml(`${qaEngine}${qaModel ? " · " + qaModelName(qaModelText(qaModel), qaEngine) : ""} · 生成中`);
|
|
1033
|
+
run.innerHTML = qaRunPillHtml(`${qaEngine}${qaModel ? " · " + qaModelName(qaModelText(qaModel, qaEngine), qaEngine) : ""} · 生成中`);
|
|
869
1034
|
s.appendChild(run);
|
|
870
1035
|
/* ⚠️ 落笔即贴底(2026-09-23,与 qaRenderMine / qaRenderDone 同一约定)。
|
|
871
1036
|
为什么必须在**这一行之后**:这条应答的立身有两块 —— 气泡节点(上面那个 div)+ 这枚
|
|
@@ -1184,6 +1349,11 @@ function qaRenderAnswer(host){
|
|
|
1184
1349
|
function askCard(name, qs, opts){
|
|
1185
1350
|
const list = Array.isArray(qs) ? qs : [];
|
|
1186
1351
|
const o = opts || {};
|
|
1352
|
+
/* 待授权(kind=permission,2026-10-25):与提问共用同一张卡,只换措辞与作答出口
|
|
1353
|
+
(允许 / 拒绝 → control 的 ask_answer;见 06-ask-answer 的 qaAskMark)。
|
|
1354
|
+
计划审批 ExitPlanMode 以前在界面上**什么都没有**(用户:「没看到审批」),
|
|
1355
|
+
而那一轮其实正卡在 CLI 里等 control_response。 */
|
|
1356
|
+
const isPerm = o.kind === "permission";
|
|
1187
1357
|
const multi = list.length > 1 || list.some(q=>q.multiSelect);
|
|
1188
1358
|
/* 收笔那枚「提交」钮**只在卡里有可多选的问时才画**(2026-10-10):单选是明确的落笔,
|
|
1189
1359
|
答满即走(见 06-ask-answer.js::qaAskAutoArm),摆一枚用不上的钮只会让人以为还差一步;
|
|
@@ -1194,26 +1364,35 @@ function qaRenderAnswer(host){
|
|
|
1194
1364
|
card.title = String(o.title || "").slice(0, 600);
|
|
1195
1365
|
/* 答案要回到哪条会话 —— 落在 **dataset** 上而不是闭包里:
|
|
1196
1366
|
快照恢复走 `innerHTML = snapshot`,闭包随旧节点一起丢,dataset 跟着 HTML 活下来,
|
|
1197
|
-
所以恢复出来的历史提问卡照样答得回去(REQ-22 锁的就是「已答态随快照恢复」)。
|
|
1367
|
+
所以恢复出来的历史提问卡照样答得回去(REQ-22 锁的就是「已答态随快照恢复」)。
|
|
1368
|
+
待授权还多两样:引擎 control_request 的 request_id(回 control_response 必须原样带回)
|
|
1369
|
+
与工具名(只用于回执/排障)。 */
|
|
1198
1370
|
if(o.sid) card.dataset.sid = String(o.sid);
|
|
1199
1371
|
if(o.rid != null && o.rid !== "") card.dataset.rid = String(o.rid);
|
|
1372
|
+
if(isPerm){
|
|
1373
|
+
card.dataset.askkind = "permission";
|
|
1374
|
+
if(o.requestId) card.dataset.requestId = String(o.requestId);
|
|
1375
|
+
if(o.toolName) card.dataset.toolName = String(o.toolName);
|
|
1376
|
+
}
|
|
1200
1377
|
card.innerHTML = `
|
|
1201
1378
|
<div class="qa-ask-hd">
|
|
1202
1379
|
<span class="qa-ask-ico">${QA_ICONS.ask}</span>
|
|
1203
1380
|
<span class="qa-ask-tag"></span>
|
|
1204
|
-
<span class="qa-ask-ttl"
|
|
1381
|
+
<span class="qa-ask-ttl">${isPerm ? "审批 · 待确认" : "提问 · 待回答"}</span>
|
|
1205
1382
|
<button type="button" class="qa-ask-x" title="收起这个确认框(不回答)" aria-label="收起这个确认框">×</button>
|
|
1206
1383
|
</div>
|
|
1207
1384
|
<div class="qa-ask-list"></div>
|
|
1208
1385
|
<div class="qa-ask-other">
|
|
1209
|
-
<input type="text" placeholder="其他:不在上面选项里就直接写,回车确认">
|
|
1210
|
-
<button type="button" class="qa-ask-go"
|
|
1386
|
+
<input type="text" placeholder="${isPerm ? "不同意就说说要改什么,回车 = 拒绝并提交意见" : "其他:不在上面选项里就直接写,回车确认"}">
|
|
1387
|
+
<button type="button" class="qa-ask-go">${isPerm ? "拒绝并说明" : "确认"}</button>
|
|
1211
1388
|
</div>
|
|
1212
1389
|
<div class="qa-ask-ft"${multi ? "" : " hidden"}>
|
|
1213
1390
|
${needSubmit ? '<button type="button" class="qa-ask-sub" disabled>提交</button>' : ""}
|
|
1214
|
-
<span class="qa-ask-hint">${
|
|
1215
|
-
?
|
|
1216
|
-
:
|
|
1391
|
+
<span class="qa-ask-hint">${isPerm
|
|
1392
|
+
? "点「允许」或「拒绝」,选完自动提交"
|
|
1393
|
+
: (needSubmit
|
|
1394
|
+
? (list.length > 1 ? "共 " + list.length + " 问 · 可多选,勾完自动提交(也可点提交)" : "可多选,勾完自动提交(也可点提交)")
|
|
1395
|
+
: "共 " + list.length + " 问,都选完自动提交")}</span>
|
|
1217
1396
|
</div>
|
|
1218
1397
|
<div class="qa-ask-ans" hidden><span class="k">你的回答</span><span class="v"></span></div>`;
|
|
1219
1398
|
/* ⚠️ 2026-09-22 审美修订:这枚标签原来**直接把引擎的工具名画在卡头上**
|
|
@@ -1222,8 +1401,17 @@ function qaRenderAnswer(host){
|
|
|
1222
1401
|
工具名降级进 title(排障时鼠标一悬停仍能看到是哪个工具)。 */
|
|
1223
1402
|
const tagEl = card.querySelector(".qa-ask-tag");
|
|
1224
1403
|
if(tagEl){
|
|
1225
|
-
tagEl.textContent = "需要你确认";
|
|
1226
|
-
tagEl.title = "引擎提问工具:" + String(name || "AskUserQuestion");
|
|
1404
|
+
tagEl.textContent = isPerm ? "需要你审批" : "需要你确认";
|
|
1405
|
+
tagEl.title = (isPerm ? "引擎审批工具:" : "引擎提问工具:") + String(name || "AskUserQuestion");
|
|
1406
|
+
}
|
|
1407
|
+
/* 待授权的 × 要如实说清后果:引擎正阻塞等这一裁决,收起卡 = 这一轮会一直等到超时
|
|
1408
|
+
(或用户点停止)。提问卡没这个语义(模型问完通常自己往下走了)。 */
|
|
1409
|
+
if(isPerm){
|
|
1410
|
+
const x = card.querySelector(".qa-ask-x");
|
|
1411
|
+
if(x){
|
|
1412
|
+
x.title = "收起(不回答:这一轮会继续等,直到你点停止或超时)";
|
|
1413
|
+
x.setAttribute("aria-label", "收起这个审批卡(不回答)");
|
|
1414
|
+
}
|
|
1227
1415
|
}
|
|
1228
1416
|
const listEl = card.querySelector(".qa-ask-list");
|
|
1229
1417
|
list.forEach((q, qi)=>{
|
|
@@ -1276,7 +1464,7 @@ function qaRenderAnswer(host){
|
|
|
1276
1464
|
done(){
|
|
1277
1465
|
if(!qaAskAnswered(card)){
|
|
1278
1466
|
const ttl = card.querySelector(".qa-ask-ttl");
|
|
1279
|
-
if(ttl) ttl.textContent = "提问 · 待回答";
|
|
1467
|
+
if(ttl) ttl.textContent = isPerm ? "审批 · 待确认" : "提问 · 待回答";
|
|
1280
1468
|
}
|
|
1281
1469
|
settled = true;
|
|
1282
1470
|
qaScrollIntoView();
|
|
@@ -1571,12 +1759,35 @@ function qaRenderAnswer(host){
|
|
|
1571
1759
|
endThinking(){ thinkEnd(); },
|
|
1572
1760
|
beginTools(){ groupBegin(); },
|
|
1573
1761
|
endTools(){ groupEnd(); },
|
|
1574
|
-
|
|
1762
|
+
/* ⚠️ 这两个流式追加出口**必须贴底**(2026-10-25 用户报障:「会话运行中显示不会自动跟着
|
|
1763
|
+
消息返回下移到底端」):本文件其余落笔处(工具卡 / 思考块开关 / 标记行 / 收尾行…)
|
|
1764
|
+
每一处都跟着一句 qaScrollIntoView,唯独流式增量这两条没有 —— 组件宿主(移动端 /
|
|
1765
|
+
PC 网页)的 render → kernelApply → qaStreamApply → appendText 全程就没有别的地方
|
|
1766
|
+
再滚了。PC 宿主靠 qaStreamBind 在 qaStreamApply 之后自己补了一次(92-composer.js),
|
|
1767
|
+
所以只有组件宿主坏掉;根因是渲染收敛进内核时,旧组件渲染器 pushItem 里那句
|
|
1768
|
+
`if (state.autoFollow) scrollBottom()` 没有对应物。补在这里 = 三端一份实现都恢复。
|
|
1769
|
+
「用户上滚看历史时停止下移」由 qaScrollIntoView 里的 qaUserAway 闸门负责
|
|
1770
|
+
(qaScrollGateBind 维护,宿主 create / qaComposerInit 已绑)—— 闸开着才贴,
|
|
1771
|
+
用户滚上去看历史时每一笔增量都直接 return,不抢他的滚动位置。 */
|
|
1772
|
+
appendThinking(t){
|
|
1773
|
+
/* 交替推理(见 ensureThink):正文正在进行时,思考**续写同一枚**思考块,
|
|
1774
|
+
不再每来一跳就新起一块 —— 否则收起态下会堆出一串「已深度思考」。 */
|
|
1775
|
+
if(curText && lastThink && !curThink){
|
|
1776
|
+
lastThink.body.textContent += t;
|
|
1777
|
+
qaScrollIntoView();
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
const c = thinkBegin(); c.body.textContent += t; qaScrollIntoView();
|
|
1781
|
+
},
|
|
1575
1782
|
appendText(t){
|
|
1783
|
+
/* 正文回来了 → 正亮着的思考到此定格(不然「思考中 · 12.3s」会一直转到收尾,
|
|
1784
|
+
与下面不断增长的正文同框)。块不搬走、正文块也不新起,见 ensureThink。 */
|
|
1785
|
+
if(curThink) thinkEnd(curThink);
|
|
1576
1786
|
ensureText(); // 没有当前正文块就按到达顺序新起一块
|
|
1577
1787
|
pending += t;
|
|
1578
1788
|
flushClosed();
|
|
1579
1789
|
renderTail();
|
|
1790
|
+
qaScrollIntoView(); // 流式增量贴底(闸门见上面的说明)
|
|
1580
1791
|
},
|
|
1581
1792
|
/** 图片块:KindImage / 原生 image 块 → data URI 直接上正文区。
|
|
1582
1793
|
* 图算**正文的一部分**(不是过程块):归到当前正文块、插在打字预览(.qa-tail)之前 ——
|
|
@@ -1727,11 +1938,18 @@ function qaDurText(ms){
|
|
|
1727
1938
|
* 给了就在状态语后面拼一段「· 总耗时 7m 14s」;不给(拿不到起点 / 老调用方)只写状态语。
|
|
1728
1939
|
* ⚠️ 与引擎自报的 `latency_ms` 不是一回事:那个只算引擎自己那一跑,不含排队与冷启动,
|
|
1729
1940
|
* 而且引擎不给就没有 —— 收尾行要的是「这一轮一共花了多久」,所以用墙上时钟自己算。 */
|
|
1730
|
-
function qaRenderDone(ok, label, host, elapsedMs){
|
|
1941
|
+
function qaRenderDone(ok, label, host, elapsedMs, detail){
|
|
1731
1942
|
// host = 这一轮的落笔处(见 qaRunHost):后台跑的收尾行要落进它自己的画布,
|
|
1732
1943
|
// 不能写死 #qa-stream —— 否则「切走那条跑完了」会在**当前显示的**会话里凭空多出一行收尾。
|
|
1733
1944
|
const s = host || $("#qa-stream"); if(!s) return;
|
|
1734
1945
|
const t = qaDurText(elapsedMs);
|
|
1946
|
+
// 失败终态:落成一张**设计过的错误卡**(见 29-msg-variants 的 qaErrorCardNode),
|
|
1947
|
+
// 而不是原来那行等宽灰字。成功 / 被停仍走下面那行轻量收尾(它们不需要卡片)。
|
|
1948
|
+
if(ok === false){
|
|
1949
|
+
s.appendChild(qaErrorCardNode({ label: label, detail: detail, elapsedMs: t }));
|
|
1950
|
+
qaScrollIntoView();
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1735
1953
|
const d = document.createElement("div");
|
|
1736
1954
|
d.className = "qa-done" + (ok ? "" : " err") + " qa-pin";
|
|
1737
1955
|
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
|
}
|
|
@@ -407,6 +448,21 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
407
448
|
detail: Object.assign({}, ev, { kind: "notice" }), status: ev.status || "" });
|
|
408
449
|
else if(typeof h.note === "function") h.note(String(ev.text || ev.notice || ""));
|
|
409
450
|
break;
|
|
451
|
+
/* 主备降级(KindFallback,2026-10-26):这一轮的主引擎失败了,下面的回答是**备用**给的。
|
|
452
|
+
与 notice 同形态(标记行 + 可展开载荷),但用 sep 图标 + 「备用引擎」字样 ——
|
|
453
|
+
用户配主备就是为了「额度用尽时还能接着答」,这一行是他确认「生效了」的唯一证据。
|
|
454
|
+
`ev.fallback` 带 from/to 两端(core 的 internal/agent/fallback.go);
|
|
455
|
+
老 core 不发这条时,这种轮次根本不会降级,不会有缺失。 */
|
|
456
|
+
case "fallback": {
|
|
457
|
+
const fb = ev.fallback || {};
|
|
458
|
+
const to = [fb.to_engine, fb.to_model].filter(Boolean).join(" · ");
|
|
459
|
+
const title = String(ev.text || ("已由备用引擎" + (to ? "「" + to + "」" : "") + "接上"));
|
|
460
|
+
if(typeof h.marker === "function")
|
|
461
|
+
h.marker({ icon: QA_V_ICONS.sep, title,
|
|
462
|
+
detail: Object.assign({}, ev, { kind: "fallback" }), status: "备用引擎" });
|
|
463
|
+
else if(typeof h.note === "function") h.note(title);
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
410
466
|
/* 团队模式的任务列表(KindTasks,见 internal/team/board.go):一条团队会话里**唯一**那张任务卡。
|
|
411
467
|
快照语义 —— core 每次任务状态变化都把整份清单重发一次(`text` = {"tasks":[…]}),
|
|
412
468
|
这里只做「整块换内容」:h.todoList 原地更新同一块面板(见 03-chat-message),
|