mingdao-harness 0.6.32 → 0.6.33
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/docs/ARCHITECTURE.md +5 -3
- package/docs/DEVELOPER.md +93 -0
- package/package.json +1 -1
- package/src/web/app.js +8 -246
- package/src/web/routes/api.js +3 -2
- package/src/web/viewmodel.js +447 -0
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# MingDao-Harness 架构设计
|
|
2
2
|
|
|
3
|
-
> 版本口径:v0.6.30。本文档覆盖 `src/` 全部 **
|
|
3
|
+
> 版本口径:v0.6.30。本文档覆盖 `src/` 全部 **91** 个模块(`find src -name '*.js' | wc -l` = 91),
|
|
4
4
|
> 按层给出每个模块的职责与它守住的那条不变量;判定以文件头注释与实际实现为准,不做推断性描述。
|
|
5
5
|
>
|
|
6
6
|
> 阅读顺序建议:§2 模块索引(查「某件事归谁管」)→ §3 数据流(看一次回合怎么走)→ §4 已知边界(看它**保证不了**什么)。
|
|
@@ -54,7 +54,7 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
|
|
|
54
54
|
|
|
55
55
|
## 2. 模块索引
|
|
56
56
|
|
|
57
|
-
以下逐文件列出 `src/` 全部
|
|
57
|
+
以下逐文件列出 `src/` 全部 91 个模块。每条格式为「职责 —— 关键不变量/边界」。
|
|
58
58
|
|
|
59
59
|
### 2.1 内核循环与上下文(6)
|
|
60
60
|
|
|
@@ -131,6 +131,8 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
|
|
|
131
131
|
- `web/constants.js` —— 前端与服务端共享常量:并发上限 8(`MAX_CONCURRENT`)、附件数与体积上限、单文件读取上限 —— 数值只在此定义,防客户端预检/服务端校验/工具读取三处漂移。
|
|
132
132
|
- `web/attachments.js` —— 聊天附件构造(纯函数):图片转 `data:` URL(仅视觉模型,单张 ≤5MB、最多 4 个),文本文件拼接进消息(≤200KB)—— 服务端按同一组常量校验。
|
|
133
133
|
- `web/web-io.js` —— WebUI 的 io 适配器:把 Agent 的 io 事件翻译为 SSE 事件,权限确认/选择类交互经 `askHandler` 转发到浏览器模态框并等待 `POST /api/permission` 应答。
|
|
134
|
+
- `web/viewmodel.js`(v0.7.0 §1.2)—— 前端的**纯计算层**:`sideNavLayout`(三档断点下的左栏形态)、`errCardParts`(错误卡文案分解)、`runStripView`(状态条六字段)、`traceRoundView`(轨迹一轮的视图模型)以及它们依赖的格式化助手。**不读 DOM、不读全局**,因此可以被 node 直测;`app.js` 只负责把结果渲染到 DOM。
|
|
135
|
+
为什么抽出来:此前 `app.js` 3252 行、可执行覆盖 0%,只靠源码正则守卫——UI 改动的"绿"等于"文本没变"。
|
|
134
136
|
- `web/util.js` / `web/app.js` / `web/i18n.js` —— 前端:`util.js` 是零状态工具(选择器/转义/Markdown/格式化),`app.js` 是页面主脚本(消息渲染与轨迹、设置面板、自绘气泡与模态框),`i18n.js` 是零依赖 UI 文案表(zh/en 同键、默认跟随 `navigator.language`、`localStorage` 覆盖)—— 地址带 `?token=` 时记入 `sessionStorage` 并从地址栏移除,此后请求统一附加 `X-MingDao-Token`。
|
|
135
137
|
|
|
136
138
|
### 2.8 CLI 层(13)
|
|
@@ -172,7 +174,7 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
|
|
|
172
174
|
- `index.js` —— 库公共 API:导出 `@stable`(minor 内向后兼容)与 `@experimental` 两组能力,契约与示例见 DEVELOPER.md。
|
|
173
175
|
|
|
174
176
|
模块计数核对:6 + 8 + 12 + 10 + 3 + 2 + 13 + 13 + 19 = **86** 个条目(含合并条目;`find src -name '*.js' | wc -l` = **90**),其中 §2.7 的
|
|
175
|
-
`web/util.js` / `web/app.js` 一条覆盖 2
|
|
177
|
+
`web/util.js` / `web/app.js` 一条覆盖 2 个文件、`web/viewmodel.js` 单列,故条目覆盖 **91** 个 `src/**/*.js`(每版本以 `find` 实测为准),
|
|
176
178
|
与 `find src -name '*.js' | wc -l` 的结果一致。
|
|
177
179
|
|
|
178
180
|
## 3. 数据流:一次 `runTurn` 的关键路径
|
package/docs/DEVELOPER.md
CHANGED
|
@@ -318,3 +318,96 @@ V8 块覆盖率(`isBlockCoverage`)的语义是:一条区间的计数只作
|
|
|
318
318
|
降序取末 8 个 = 最小的 8 个文件,所以那行标题下的名单从来没和"最低覆盖"对应过)。
|
|
319
319
|
- 基线是在本机 Node v24 / macOS 上实测生成的;CI 覆盖率腿跑 Linux + Node 20。V8 对同一份源码
|
|
320
320
|
的块区间理论上一致,若 CI 实测略有出入,应按 CI 的实测值重建基线并在提交说明里写明原因。
|
|
321
|
+
|
|
322
|
+
## 八、变异覆盖盲区检查(v0.7.0 §1.2)
|
|
323
|
+
|
|
324
|
+
> 变异验证回答的是「**改坏了的代码会不会被抓到**」;它不回答「从没被改过的模块安不安全」。
|
|
325
|
+
> 2026-11 的自审实测:633→669 条变异里 4 个文件占了 285 条,而 90 个模块里 43 个**一条变异都没有**。
|
|
326
|
+
> 分数高只说明「改过的都测了」,盲区模块在报告里是**沉默**的——这一节把沉默变成可复跑的清单。
|
|
327
|
+
|
|
328
|
+
### 8.1 三件套:行为套件 → 变异批次 → 盲区检查
|
|
329
|
+
|
|
330
|
+
给一个「有真实逻辑但 0 条变异」的模块补覆盖,顺序不能颠倒:
|
|
331
|
+
|
|
332
|
+
1. **先建行为套件**(`test/<模块>-<主题>.js`)。没有套件就没有可抓的变异。
|
|
333
|
+
- 调**真实函数**看结果,不做源码文本匹配;
|
|
334
|
+
- 用本机临时目录(`fs.mkdtempSync(path.join(os.tmpdir(), ...))`)+ 桩服务端,不连网;
|
|
335
|
+
- 每条断言带稳定前缀(如 `E1:`、`P7:`、`W13:`),后面写变异批次要用它当关键词。
|
|
336
|
+
2. **再写变异批次**(`test/mutate/batchNN-<主题>.mjs`,参照 `batch72`/`73`/`74`)。
|
|
337
|
+
- 只改 `src/**` 里**最容易写错**的那一行:整键替换 vs 合并、mtime 丢失、围栏少一层 realpath、
|
|
338
|
+
软链不判、能力声明不校验、错误被吞……;
|
|
339
|
+
- 每条都在套件上**真跑一遍**,必须红;
|
|
340
|
+
- `expect` 填**失败输出里真实出现的关键词**(断言原文的一部分)。
|
|
341
|
+
本仓在这上面踩过两次:一次是 `expect` 写了同一断言组里另一条分支的文案;一次是
|
|
342
|
+
`'工具名非法'` 同时出现在 `registerTool` 的兜底报错里,变异把 Pack 层主动告警删掉后
|
|
343
|
+
**仍然命中**、看起来"抓到了"。所以 `expect` 要尽量长、尽量是**你删掉的那条路径**独有的话。
|
|
344
|
+
3. **跑一遍盲区检查**(下节),确认这个模块从清单里消失。
|
|
345
|
+
|
|
346
|
+
### 8.2 盲区检查:`scripts/mutation-coverage.mjs`
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
node scripts/mutation-coverage.mjs # 只报告(退出 0)
|
|
350
|
+
node scripts/mutation-coverage.mjs --fail-over 0 # 盲区数 > 0 即失败(CI 卡新增盲区)
|
|
351
|
+
node scripts/mutation-coverage.mjs --fail-over 13 # 只允许存量 13 个(存量基线,逐步下调)
|
|
352
|
+
node scripts/mutation-coverage.mjs --min-lines 150 # 调整"有真实逻辑"的代码行门槛
|
|
353
|
+
node scripts/mutation-coverage.mjs --json # 机器可读
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
口径(简单、可解释):
|
|
357
|
+
|
|
358
|
+
- **代码行** = 去掉空行与纯注释行后的行数(逐字符识别 `//`、`/* */`;字符串里的 `//` 不算注释)。
|
|
359
|
+
- **变异条数** = 该文件在全部 `test/mutate/batch*.mjs` 的 `file:` 字段里出现的次数(只统计 `src/**`)。
|
|
360
|
+
- **盲区** = 代码行 > `--min-lines`(默认 150)且变异条数 = 0。
|
|
361
|
+
|
|
362
|
+
`--fail-over N` 的语义:**盲区模块数 > N 时非零退出**;不带该参数绝不失败(只报告)。
|
|
363
|
+
CI 想"只卡新增盲区"就用当前存量数当基线(例如 `--fail-over 13`),想"一个都不能有"就用 0。
|
|
364
|
+
本任务**未**把它接进 `.github/workflows/ci.yml`(不在允许改动范围)——接线时注意:直接上
|
|
365
|
+
`--fail-over 0` 会让今天的存量盲区全部变红,应先按存量收敛。
|
|
366
|
+
|
|
367
|
+
### 8.3 当前盲区清单(2026-11-04,v0.6.32 / HEAD b5cd199 + 本任务三批变异)
|
|
368
|
+
|
|
369
|
+
`node scripts/mutation-coverage.mjs` 的实跑输出(如实抄录;数字随后续批次变化,**以脚本输出为准**):
|
|
370
|
+
|
|
371
|
+
```text
|
|
372
|
+
变异覆盖盲区检查(阈值:代码行 > 150 且 0 条变异)
|
|
373
|
+
src 模块:91 个;src/** 变异条目:630 条;盲区模块:13 个
|
|
374
|
+
|
|
375
|
+
模块(按代码行降序):
|
|
376
|
+
模块 代码行 变异
|
|
377
|
+
src/ui.js 600 0
|
|
378
|
+
src/mcp.js 303 0
|
|
379
|
+
src/tokenizer.js 302 0
|
|
380
|
+
src/commands/sync.js 288 0
|
|
381
|
+
src/update.js 244 0
|
|
382
|
+
src/web/viewmodel.js 243 0
|
|
383
|
+
src/web/i18n.js 225 0
|
|
384
|
+
src/commands/skill.js 218 0
|
|
385
|
+
src/web/util.js 198 0
|
|
386
|
+
src/commands/update.js 187 0
|
|
387
|
+
src/commands/schedule.js 181 0
|
|
388
|
+
src/skill-registry.js 171 0
|
|
389
|
+
src/compact.js 154 0
|
|
390
|
+
|
|
391
|
+
合计:13 个模块、3314 行代码没有可抓的变异。
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
本任务补掉的三处(此前均 0 条):
|
|
395
|
+
|
|
396
|
+
| 模块 | 行为套件 | 变异批次 | 条数 |
|
|
397
|
+
| --- | --- | --- | --- |
|
|
398
|
+
| `src/sync.js` 状态增量 / 冲突 / 边界 | `test/sync-state.js`(16 项) | `test/mutate/batch72-sync-state.mjs` | 10 |
|
|
399
|
+
| `src/packs.js` manifest / 版本 / 能力声明 / 信任门 | `test/packs-capabilities.js`(16 项) | `test/mutate/batch73-packs.mjs` | 10 |
|
|
400
|
+
| `src/workspace.js` 注册表 / 归一 / 跨进程锁 | `test/workspace-fence.js`(15 项) | `test/mutate/batch74-workspace.mjs` | 11 |
|
|
401
|
+
|
|
402
|
+
> 模块边界如实说明:`src/workspace.js` 本身**不含**路径围栏(`withinRoot`),围栏真实实现于
|
|
403
|
+
> `src/tools/fs-tools.js` 与 `src/web/routes/domains/workspace.js`。`batch74` 的 11 条里有 8 条打在
|
|
404
|
+
> `src/workspace.js`,2 条打在前者的 `withinRoot`,2 条打在后者(允许根 / realpath 两层围栏)。
|
|
405
|
+
> 另外 `src/sync.js` 在本任务前已有 1 条(`batch70` ⑦),并非严格 0 条。
|
|
406
|
+
|
|
407
|
+
### 8.4 边界
|
|
408
|
+
|
|
409
|
+
- 本任务的三个新套件**没有**接进 `test/run-all.mjs` 与 CI(不在允许改动范围)。要接的话,
|
|
410
|
+
在 `SUITES` 里加 `['sync-state', 'node', ['test/sync-state.js']]` 等三行即可;
|
|
411
|
+
三个套件都是零外部依赖、只用本机临时目录。
|
|
412
|
+
- 盲区检查只回答"有没有变异",不回答"变异质量"——`--fail-over` 只能挡住新增盲区,
|
|
413
|
+
挡不住"给一个 600 行模块写 1 条无关变异"这种凑数。质量仍靠 review 与 `expect` 关键词约束。
|
package/package.json
CHANGED
package/src/web/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { $, esc, highlight, renderMarkdown, scrollBottom, expandableBody, resultText, truncText, textBody, fmtDur, fmtTok, fmtT } from './util.js';
|
|
2
2
|
import { MAX_ATTACHMENTS, MAX_IMAGE_BYTES, MAX_TEXT_BYTES } from './constants.js';
|
|
3
|
+
import { sideNavBreakpoint, sideNavLayout, errCardParts, runStripView, permissionSourceLabel, toolStepView, toolTimelineSummary, toolTimelineVisibility, traceRoundView } from './viewmodel.js';
|
|
3
4
|
|
|
4
5
|
// 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
|
|
5
6
|
// 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
|
|
@@ -349,18 +350,8 @@ function toggleRecentSessions() {
|
|
|
349
350
|
writeRecentCollapsed(storage, recentCollapsed);
|
|
350
351
|
syncRecentCollapsed(recentCollapsed);
|
|
351
352
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const w = Number(width) || 0;
|
|
355
|
-
if (w >= 1180) return 'wide';
|
|
356
|
-
if (w >= 900) return 'mid';
|
|
357
|
-
return 'drawer';
|
|
358
|
-
}
|
|
359
|
-
function sideNavLayout(width, pref) {
|
|
360
|
-
const mode = sideNavBreakpoint(width);
|
|
361
|
-
const collapsed = mode === 'mid' || (mode === 'wide' && !!pref);
|
|
362
|
-
return { mode, collapsed, width: mode === 'drawer' ? 0 : (collapsed ? 48 : 240), drawer: mode === 'drawer' };
|
|
363
|
-
}
|
|
353
|
+
// 左栏三档断点/布局是纯计算,已搬到 viewmodel.js(sideNavBreakpoint / sideNavLayout);
|
|
354
|
+
// 这里只消费结果,类名映射(sideNavClassNames)仍留在 DOM 层。
|
|
364
355
|
function sideNavClassNames(layout) {
|
|
365
356
|
const out = [];
|
|
366
357
|
if (layout.mode === 'wide') out.push('side-wide');
|
|
@@ -555,17 +546,7 @@ function renderTodos(todos,msg){
|
|
|
555
546
|
// —— 错误卡:三段式(纯函数;smoke §148 直接 eval 这段做行为断言) ——
|
|
556
547
|
// 上游原文保留换行(<pre> + white-space:pre-wrap),默认折叠只显示首行摘要;
|
|
557
548
|
// 处置建议独立成一行(.err-hint,white-space:pre-line),与原文明分;长错误可一键复制。
|
|
558
|
-
|
|
559
|
-
const rawText=String(text||'').replace(/\r\n?/g,'\n');
|
|
560
|
-
const lines=rawText.split('\n');
|
|
561
|
-
let hintIdx=-1;
|
|
562
|
-
for(let i=0;i<lines.length;i++){ if(/^\s*(?:💡|提示[::]|处置建议[::])/.test(lines[i])){ hintIdx=i; break; } }
|
|
563
|
-
const hint=hintIdx>=0?lines[hintIdx].trim():'';
|
|
564
|
-
const raw=(hintIdx>=0?lines.slice(0,hintIdx):lines).join('\n').trim()||rawText.trim();
|
|
565
|
-
const head=(lines[0]||'').trim();
|
|
566
|
-
const tag=/上游|upstream/i.test(rawText)?'上游':/超时|timeout/i.test(rawText)?'内核':'错误';
|
|
567
|
-
return {head,hint,raw,tag};
|
|
568
|
-
}
|
|
549
|
+
// 三段式的纯计算已搬到 viewmodel.js(errCardParts);errCardHtml 只做字符串拼装。
|
|
569
550
|
function errCardHtml(text){
|
|
570
551
|
const p=errCardParts(text);
|
|
571
552
|
const summary=p.head.length>120?p.head.slice(0,120)+'…':p.head;
|
|
@@ -984,56 +965,10 @@ async function stopTurnFromButton(){
|
|
|
984
965
|
}
|
|
985
966
|
|
|
986
967
|
// —— 输入框上方工作状态条(P0-1 起由下面的 #runStrip 常驻状态条取代;保留本行作为既有守卫的定位锚点)
|
|
987
|
-
// ——
|
|
968
|
+
// —— 运行状态条:字段口径(纯函数;已搬到 viewmodel.js,smoke 直测 runStripView) ——
|
|
988
969
|
// 阶段只有五档(等首帧 / 解码中 / 执行工具 / 已中止 / 空闲)——服务端的 phase 文案更细
|
|
989
970
|
// (发请求(等首帧)/ 解码中(收到增量)/ 执行工具 bash / 排队等待本地端点…),这里收敛成用户能一眼读懂的五档。
|
|
990
|
-
|
|
991
|
-
if(aborted) return '已中止';
|
|
992
|
-
if(!isGenerating) return '空闲';
|
|
993
|
-
const p=String(rawPhase||'');
|
|
994
|
-
if(/已中止|已停止|aborted|stalled/.test(p)) return '已中止';
|
|
995
|
-
if(/解码/.test(p)) return '解码中';
|
|
996
|
-
if(/执行工具|等待权限确认|待发请求|子\s?Agent|工具/.test(p)) return '执行工具';
|
|
997
|
-
return '等首帧';
|
|
998
|
-
}
|
|
999
|
-
function fmtRunTtft(ms){ return ms==null || !Number.isFinite(Number(ms)) ? '—' : (Number(ms)/1000).toFixed(1)+'s'; }
|
|
1000
|
-
function fmtRunTps(v){ return v==null || !Number.isFinite(Number(v)) ? '—' : Number(v).toFixed(1); }
|
|
1001
|
-
function fmtRunTools(n){ const k=Math.max(0,Math.round(Number(n)||0)); return k===0 ? '0 次工具调用' : k+' 次工具调用'; }
|
|
1002
|
-
function fmtRunCost(cost){ const m=String(cost||'').match(/≈¥[0-9.]+/); return m ? m[0] : ''; }
|
|
1003
|
-
function fmtRunCtx(st){
|
|
1004
|
-
const total=Number(st.contextTotal)||0;
|
|
1005
|
-
const used=Math.max(0,Math.round(Number(st.contextUsed)||0));
|
|
1006
|
-
if(!total && !used) return '—';
|
|
1007
|
-
// 生成中没有实时 used tokens(内核 budget 只在 done 算):用「上一轮 used + 本轮字符估算」,
|
|
1008
|
-
// 必须标 ≈;done 带回 budget 后才是精确值(去掉 ≈)。
|
|
1009
|
-
const approx=st.contextApprox !== false;
|
|
1010
|
-
return (approx?'≈ ':'')+fmtTok(used)+' / '+(total?fmtTok(total):'?')+' tokens';
|
|
1011
|
-
}
|
|
1012
|
-
function runStripView(st, isGenerating){
|
|
1013
|
-
return {
|
|
1014
|
-
phase: normalizeRunPhase(st.rawPhase, isGenerating !== false, st.aborted),
|
|
1015
|
-
ttft: fmtRunTtft(st.ttftMs),
|
|
1016
|
-
tps: fmtRunTps(st.tokensPerSec),
|
|
1017
|
-
ctx: fmtRunCtx(st),
|
|
1018
|
-
tools: fmtRunTools(st.toolCalls),
|
|
1019
|
-
cost: fmtRunCost(st.cost) || '—',
|
|
1020
|
-
};
|
|
1021
|
-
}
|
|
1022
|
-
/** 把一条 progress 事件并进运行状态(桩 SSE 与真实 SSE 走同一条路径)。 */
|
|
1023
|
-
function applyRunProgress(st, ev, baseUsed){
|
|
1024
|
-
st.rawPhase=String(ev.phase||'');
|
|
1025
|
-
st.phase=normalizeRunPhase(st.rawPhase, true, false);
|
|
1026
|
-
if(ev.ttftMs!=null) st.ttftMs=Number(ev.ttftMs)||0;
|
|
1027
|
-
st.tokensPerSec=ev.tokensPerSec!=null ? Number(ev.tokensPerSec) : null;
|
|
1028
|
-
st.toolCalls=Number(ev.toolCalls)||0;
|
|
1029
|
-
st.contentChars=Number(ev.contentChars)||0;
|
|
1030
|
-
st.contextApprox=true;
|
|
1031
|
-
st.contextUsed=Math.max(0, Number(baseUsed)||0) + Math.round(st.contentChars/1.5);
|
|
1032
|
-
return st;
|
|
1033
|
-
}
|
|
1034
|
-
function permissionSourceLabel(source){
|
|
1035
|
-
return ({explicit:'显式选择',preset:'预设',config:'config 默认',cli:'命令行',default:'内置默认'})[String(source||'')] || '未知';
|
|
1036
|
-
}
|
|
971
|
+
// runStripView / applyRunProgress / permissionSourceLabel 及六个 fmtRun* 全部在 viewmodel.js。
|
|
1037
972
|
|
|
1038
973
|
// —— 状态条渲染(**唯一**刷新出口;合并原 #workStatus / 顶部活动条 / 消息内气泡三处) ——
|
|
1039
974
|
function renderWorkStatus(){
|
|
@@ -1075,116 +1010,7 @@ function renderWorkStatus(){
|
|
|
1075
1010
|
// 会话设置文案。DOM 渲染在下面各自的小节里,只消费这些结果。
|
|
1076
1011
|
// ============================================================================
|
|
1077
1012
|
|
|
1078
|
-
// ——
|
|
1079
|
-
const TOOL_TL_DEFAULT_VISIBLE = 3;
|
|
1080
|
-
const TOOL_OUTPUT_PREVIEW = 900;
|
|
1081
|
-
/** 工具参数的**一行**摘要(完整参数在展开区里用 JSON 显示)。 */
|
|
1082
|
-
function toolArgsSummary(name, args) {
|
|
1083
|
-
const a = args || {};
|
|
1084
|
-
if (name === 'bash') return String(a.command || '');
|
|
1085
|
-
if (name === 'task') return String(a.question || a.prompt || a.description || '');
|
|
1086
|
-
return String(a.path || a.pattern || a.name || a.query || a.url || a.file || '');
|
|
1087
|
-
}
|
|
1088
|
-
/** 工具结果的一行摘要(不是正文;正文在展开区)。 */
|
|
1089
|
-
function toolResultSummary(ev) {
|
|
1090
|
-
if (!ev || ev.result === undefined) return '执行中…';
|
|
1091
|
-
const r = ev.result || {};
|
|
1092
|
-
if (r.ok === false) return r.error ? String(r.error).split('\n')[0] : '失败';
|
|
1093
|
-
if (ev.name === 'bash') return r.timedOut ? '超时' : 'exit ' + (r.exitCode != null ? r.exitCode : 0);
|
|
1094
|
-
if (ev.name === 'skill') {
|
|
1095
|
-
const m = String(r.output || '').match(/^description:\s*(.+)$/m);
|
|
1096
|
-
return m ? m[1].slice(0, 100) : '已加载技能';
|
|
1097
|
-
}
|
|
1098
|
-
if (ev.name === 'write') return r.bytes != null ? r.bytes + ' 字节' : '已写入';
|
|
1099
|
-
if (ev.name === 'edit') return '已修改';
|
|
1100
|
-
if (ev.name === 'read') return r.lines != null ? r.lines + ' 行' : '读取完成';
|
|
1101
|
-
if (ev.name === 'todo') return '更新任务清单';
|
|
1102
|
-
const out = String(r.output || r.stdout || r.stderr || '');
|
|
1103
|
-
return out.split('\n')[0].slice(0, 120);
|
|
1104
|
-
}
|
|
1105
|
-
/** 工具输出的完整文本(展开区用)。 */
|
|
1106
|
-
function toolOutputText(ev) {
|
|
1107
|
-
const r = (ev && ev.result) || {};
|
|
1108
|
-
if (ev && ev.name === 'skill') return String(r.output || '');
|
|
1109
|
-
if (r.diff) {
|
|
1110
|
-
const before = String(r.diff.before || '').split('\n');
|
|
1111
|
-
const after = String(r.diff.after || '').split('\n');
|
|
1112
|
-
const n = Math.max(before.length, after.length);
|
|
1113
|
-
const out = [];
|
|
1114
|
-
for (let i = 0; i < n; i++) {
|
|
1115
|
-
if (before[i] === after[i]) out.push(' ' + (before[i] || ''));
|
|
1116
|
-
else {
|
|
1117
|
-
if (before[i] !== undefined) out.push('- ' + before[i]);
|
|
1118
|
-
if (after[i] !== undefined) out.push('+ ' + after[i]);
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
return out.join('\n');
|
|
1122
|
-
}
|
|
1123
|
-
if (ev && ev.name === 'bash') return [r.stdout, r.stderr].filter(Boolean).join('\n') || ('exit ' + (r.exitCode != null ? r.exitCode : 0));
|
|
1124
|
-
if (r.output != null) return String(r.output);
|
|
1125
|
-
if (r.error != null) return String(r.error);
|
|
1126
|
-
return ev && ev.result !== undefined ? JSON.stringify(r, null, 2) : '';
|
|
1127
|
-
}
|
|
1128
|
-
/** 长输出折叠判据:给出完整文本、预览、行数与字节数(用户要知道被折了多少)。 */
|
|
1129
|
-
function toolOutputStats(text, limit) {
|
|
1130
|
-
const full = String(text == null ? '' : text);
|
|
1131
|
-
const max = Number(limit) || TOOL_OUTPUT_PREVIEW;
|
|
1132
|
-
const lines = full === '' ? 0 : full.split('\n').length;
|
|
1133
|
-
let bytes = full.length;
|
|
1134
|
-
try { bytes = new TextEncoder().encode(full).length; } catch { bytes = full.length; }
|
|
1135
|
-
const truncated = full.length > max || lines > 40;
|
|
1136
|
-
const preview = !truncated ? full : (full.length > max ? full.slice(0, max) : full.split('\n').slice(0, 40).join('\n'));
|
|
1137
|
-
return { full, preview, lines, bytes, truncated };
|
|
1138
|
-
}
|
|
1139
|
-
/** 单步视图(失败高亮类名 / 状态图标 / 一行摘要 / 完整参数与输出)。 */
|
|
1140
|
-
function toolStepView(ev, done) {
|
|
1141
|
-
const isDone = done !== undefined ? Boolean(done) : ev.result !== undefined || ev.denied;
|
|
1142
|
-
const failed = Boolean(ev.denied) || (ev.result !== undefined && ev.result && ev.result.ok === false);
|
|
1143
|
-
const out = toolOutputStats(toolOutputText(ev));
|
|
1144
|
-
return {
|
|
1145
|
-
seq: ev.seq,
|
|
1146
|
-
name: String(ev.name || 'tool'),
|
|
1147
|
-
args: toolArgsSummary(ev.name, ev.args),
|
|
1148
|
-
argsJson: JSON.stringify(ev.args || {}, null, 2),
|
|
1149
|
-
durationMs: ev.durationMs != null ? Number(ev.durationMs) : null,
|
|
1150
|
-
dur: ev.durationMs != null ? fmtToolDur(ev.durationMs) : (isDone ? '—' : '执行中…'),
|
|
1151
|
-
done: isDone,
|
|
1152
|
-
failed,
|
|
1153
|
-
stIcon: !isDone ? '…' : failed ? '✖' : '✓',
|
|
1154
|
-
stCls: !isDone ? '' : failed ? 'bad' : 'ok',
|
|
1155
|
-
result: ev.denied ? String(ev.denied || '未授权') : toolResultSummary(ev),
|
|
1156
|
-
output: out,
|
|
1157
|
-
liClass: 'tl-step' + (failed ? ' fail' : ''),
|
|
1158
|
-
};
|
|
1159
|
-
}
|
|
1160
|
-
function fmtToolDur(ms) {
|
|
1161
|
-
const n = Number(ms) || 0;
|
|
1162
|
-
return n < 1000 ? n + 'ms' : (n / 1000).toFixed(1) + 's';
|
|
1163
|
-
}
|
|
1164
|
-
/** 时间线汇总:步数 / 失败数 / 总耗时 / 头行文案。 */
|
|
1165
|
-
function toolTimelineSummary(items) {
|
|
1166
|
-
const list = items || [];
|
|
1167
|
-
const failed = list.filter((s) => s.failed).length;
|
|
1168
|
-
const totalMs = list.reduce((a, s) => a + (Number(s.durationMs) || 0), 0);
|
|
1169
|
-
const detail = list.length + ' 步' + (failed ? ' · ' + failed + ' 个失败' : '');
|
|
1170
|
-
return {
|
|
1171
|
-
count: list.length,
|
|
1172
|
-
failed,
|
|
1173
|
-
totalMs,
|
|
1174
|
-
countText: detail,
|
|
1175
|
-
sumText: totalMs ? fmtToolDur(totalMs) : '',
|
|
1176
|
-
};
|
|
1177
|
-
}
|
|
1178
|
-
/** 默认可见步:前 3 步 + 所有失败步(失败步免展开可见)。 */
|
|
1179
|
-
function toolTimelineVisibility(items, expanded) {
|
|
1180
|
-
const list = items || [];
|
|
1181
|
-
if (expanded) return list.map((_, i) => i);
|
|
1182
|
-
const keep = new Set();
|
|
1183
|
-
for (let i = 0; i < Math.min(TOOL_TL_DEFAULT_VISIBLE, list.length); i++) keep.add(i);
|
|
1184
|
-
list.forEach((s, i) => { if (s.failed) keep.add(i); });
|
|
1185
|
-
return [...keep].sort((a, b) => a - b);
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1013
|
+
// —— 工具时间线 / 轨迹视图(纯函数;已搬到 viewmodel.js,smoke 直测 toolStepView / traceRoundView) ——
|
|
1188
1014
|
// —— 右侧「本轮运行」:六项字段与状态条同源(都从 runStripView 派生) ——
|
|
1189
1015
|
function runPanelView(st, isGenerating) {
|
|
1190
1016
|
const base = runStripView(st, isGenerating);
|
|
@@ -1350,71 +1176,7 @@ function applyThemePreference(pref, deps) {
|
|
|
1350
1176
|
// —— 轨迹 / 子 Agent 视图(v0.6.21 §3.73;smoke §151 直接 eval 这段做行为断言) ——
|
|
1351
1177
|
// 唯一统计口径:对话流每轮那一行摘要与右栏「轨迹」都走 traceRoundView / traceSummaryText,
|
|
1352
1178
|
// 由 traceRoundForMsg 统一装配。**不允许**两处各算一套(§151 断言两处都调用同一函数)。
|
|
1353
|
-
|
|
1354
|
-
const total = Math.max(0, Math.round((Number(ms) || 0) / 1000));
|
|
1355
|
-
if (total < 60) return total + 's';
|
|
1356
|
-
const m = Math.floor(total / 60);
|
|
1357
|
-
const s = total % 60;
|
|
1358
|
-
if (m < 60) return m + 'm' + (s ? s + 's' : '');
|
|
1359
|
-
const h = Math.floor(m / 60);
|
|
1360
|
-
const rm = m % 60;
|
|
1361
|
-
return h + 'h' + (rm ? rm + 'm' : '');
|
|
1362
|
-
}
|
|
1363
|
-
/** 子代理一行的视图:名称 · 轮次 · 工具次数 · 最后进展 · 状态(缺失字段如实显示 —,不编造)。 */
|
|
1364
|
-
function subagentRowView(sub) {
|
|
1365
|
-
const s = sub || {};
|
|
1366
|
-
const toolCalls = s.toolCalls == null || !Number.isFinite(Number(s.toolCalls)) ? null : Math.max(0, Math.round(Number(s.toolCalls)));
|
|
1367
|
-
const modelRounds = s.modelRounds == null || !Number.isFinite(Number(s.modelRounds)) ? null : Math.max(0, Math.round(Number(s.modelRounds)));
|
|
1368
|
-
const ago = s.lastProgressAgoMs == null || !Number.isFinite(Number(s.lastProgressAgoMs)) ? null : Math.max(0, Math.round(Number(s.lastProgressAgoMs)));
|
|
1369
|
-
const running = Boolean(s.running);
|
|
1370
|
-
const failed = !running && (s.status === 'failed' || Boolean(s.result && s.result.ok === false));
|
|
1371
|
-
const status = running ? 'running' : failed ? 'failed' : 'done';
|
|
1372
|
-
const round = Math.max(0, Math.round(Number(s.round) || 0));
|
|
1373
|
-
return {
|
|
1374
|
-
key: 'sub:' + String(s.seq == null ? '?' : s.seq),
|
|
1375
|
-
seq: s.seq,
|
|
1376
|
-
round,
|
|
1377
|
-
roundText: round ? '第 ' + round + ' 轮' : '—',
|
|
1378
|
-
name: String(s.label || s.description || s.question || '子代理').slice(0, 80),
|
|
1379
|
-
question: String(s.question || ''),
|
|
1380
|
-
toolCalls,
|
|
1381
|
-
toolText: toolCalls == null ? '工具次数 —' : toolCalls + ' 次工具',
|
|
1382
|
-
modelRounds,
|
|
1383
|
-
roundsText: modelRounds == null ? '自身轮次 —' : modelRounds + ' 轮',
|
|
1384
|
-
progress: ago == null ? '最后进展 —' : '最后进展 ' + (ago < 1000 ? '<1s' : Math.round(ago / 1000) + 's') + ' 前',
|
|
1385
|
-
status,
|
|
1386
|
-
statusText: running ? '运行中' : failed ? '失败' : '完成',
|
|
1387
|
-
statusCls: running ? 'run' : failed ? 'bad' : 'ok',
|
|
1388
|
-
running,
|
|
1389
|
-
durationMs: s.durationMs == null ? null : Number(s.durationMs),
|
|
1390
|
-
result: s.result,
|
|
1391
|
-
};
|
|
1392
|
-
}
|
|
1393
|
-
/** 一轮的完整视图(工具步 + 子代理 + 摘要文案);对话流与右栏消费同一个对象。 */
|
|
1394
|
-
function traceRoundView(round) {
|
|
1395
|
-
const r = round || {};
|
|
1396
|
-
const now = Number(r.now) || Date.now();
|
|
1397
|
-
const tools = (r.tools || []).map((t) => toolStepView(t, t.done));
|
|
1398
|
-
const subs = (r.subs || []).map(subagentRowView);
|
|
1399
|
-
const started = Number(r.startedAt) || 0;
|
|
1400
|
-
const ended = r.endedAt != null ? Number(r.endedAt) : now;
|
|
1401
|
-
const durationMs = started ? Math.max(0, ended - started) : 0;
|
|
1402
|
-
const durationText = started ? fmtTraceDur(durationMs) : '';
|
|
1403
|
-
const parts = ['本轮 ' + tools.length + ' 次工具调用'];
|
|
1404
|
-
if (subs.length) parts.push(subs.length + ' 个子代理');
|
|
1405
|
-
if (durationText) parts.push(durationText);
|
|
1406
|
-
return {
|
|
1407
|
-
round: Math.max(0, Math.round(Number(r.round) || 0)),
|
|
1408
|
-
tools,
|
|
1409
|
-
subs,
|
|
1410
|
-
toolCount: tools.length,
|
|
1411
|
-
subCount: subs.length,
|
|
1412
|
-
failed: tools.filter((t) => t.failed).length,
|
|
1413
|
-
durationMs,
|
|
1414
|
-
durationText,
|
|
1415
|
-
summary: parts.join(' · '),
|
|
1416
|
-
};
|
|
1417
|
-
}
|
|
1179
|
+
// fmtTraceDur / subagentRowView / traceRoundView 已搬到 viewmodel.js;下面只保留数据装配与文案读取。
|
|
1418
1180
|
function traceSummaryText(view) { return String((view && view.summary) || ''); }
|
|
1419
1181
|
/** 每轮的数据装配:只读 msg._traj 与传入的 sessionSubs/pending,不读 runState、不另算。 */
|
|
1420
1182
|
function traceRoundData(msg, subs, pending, now) {
|
package/src/web/routes/api.js
CHANGED
|
@@ -32,7 +32,7 @@ export function createApiDispatch(deps) {
|
|
|
32
32
|
// 访问控制(P1-3/P1-4):token 校验覆盖数据与操作接口;壳页面与 PWA 静态资源公开
|
|
33
33
|
// (壳不含任何数据,SPA 需要先加载才能读取 ?token=);Host 白名单覆盖一切请求
|
|
34
34
|
const isStaticAsset =
|
|
35
|
-
p === '/' || p === '/index.html' || p === '/app.js' || p === '/util.js' || p === '/constants.js' || p === '/favicon.ico' || p === '/icon.svg' || p === '/icon-192.png' || p === '/icon-512.png' || p === '/manifest.webmanifest' || p === '/sw.js';
|
|
35
|
+
p === '/' || p === '/index.html' || p === '/app.js' || p === '/util.js' || p === '/constants.js' || p === '/viewmodel.js' || p === '/favicon.ico' || p === '/icon.svg' || p === '/icon-192.png' || p === '/icon-512.png' || p === '/manifest.webmanifest' || p === '/sw.js';
|
|
36
36
|
if (authEnabled && !isStaticAsset && !tokenMatches(requestToken(req, url))) {
|
|
37
37
|
return json(res, 401, { error: '未授权:缺少或无效的访问令牌(地址需带 ?token=…,或请求头携带 X-MingDao-Token)' });
|
|
38
38
|
}
|
|
@@ -90,7 +90,8 @@ export function createApiDispatch(deps) {
|
|
|
90
90
|
// —— 静态壳资源(公开;不经过域分发) ——
|
|
91
91
|
// 质检 Q2/S5:SPA 外部 JS——从磁盘读取(与 index.html 同目录)。
|
|
92
92
|
// v0.2.8 C2:app.js 拆分为 ES Modules(app.js / util.js / constants.js),统一从此处伺服。
|
|
93
|
-
|
|
93
|
+
// v0.7.0 §1.2:viewmodel.js(纯视图模型)与 app.js 同目录、同源伺服(不新增接口,只加白名单)。
|
|
94
|
+
const WEB_JS_FILES = new Set(['app.js', 'util.js', 'constants.js', 'viewmodel.js']);
|
|
94
95
|
if (method === 'GET' && WEB_JS_FILES.has(p.slice(1))) {
|
|
95
96
|
try {
|
|
96
97
|
const js = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', p.slice(1)), 'utf8');
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// 前端纯视图模型(v0.7.0 §1.2):从 app.js 搬出的可直测计算层。
|
|
2
|
+
//
|
|
3
|
+
// 设计约束(与 smoke §148/§149/§151/§158 的行为断言一致):
|
|
4
|
+
// · 不读 DOM、不读窗口尺寸、不读 localStorage、不读写任何模块级可变状态;
|
|
5
|
+
// · 只依赖入参(以及 util.js 的纯格式化函数),同样的输入永远得到同样的输出;
|
|
6
|
+
// · 输出字段名就是 app.js 渲染层消费的字段名——改名会当场打破 smoke 的形状断言。
|
|
7
|
+
//
|
|
8
|
+
// 本文件只做"纯计算",DOM 接线仍在 app.js。test/viewmodel.js 用 node 直跑,
|
|
9
|
+
// 并把搬家前的原实现当作"参照实现"逐组对拍,证明这次搬家没有改变行为。
|
|
10
|
+
|
|
11
|
+
// 本模块刻意不 import util.js:util.js 依赖 document,属于浏览器层;tsconfig.full 的
|
|
12
|
+
// exclude 不阻断"被 import 的文件"进入类型检查。这里只复制一个纯格式化函数并逐值对拍。
|
|
13
|
+
/**
|
|
14
|
+
* tokens 数量缩写(与 util.js 的 fmtTok 逐字节一致;test/viewmodel.js 对数)。
|
|
15
|
+
*
|
|
16
|
+
* @param {number} n
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function fmtTok(n) {
|
|
20
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
|
21
|
+
if (n >= 1e6) return (n / 1e6).toFixed(0) + 'M';
|
|
22
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
23
|
+
return String(n);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 左栏三档断点:≥1180 展开(240) / 900–1180 自动折叠(48) / <900 抽屉。
|
|
28
|
+
* 右栏收起点见 app.js 的 layoutClassesFor(<1100)。
|
|
29
|
+
*
|
|
30
|
+
* @param {number|string} width 视口宽度;非数字/缺省按 0 处理
|
|
31
|
+
* @returns {'wide'|'mid'|'drawer'}
|
|
32
|
+
*/
|
|
33
|
+
export function sideNavBreakpoint(width) {
|
|
34
|
+
const w = Number(width) || 0;
|
|
35
|
+
if (w >= 1180) return 'wide';
|
|
36
|
+
if (w >= 900) return 'mid';
|
|
37
|
+
return 'drawer';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 左栏布局:把断点与用户折叠偏好收敛成渲染层直接消费的对象。
|
|
42
|
+
*
|
|
43
|
+
* @param {number|string} width 视口宽度
|
|
44
|
+
* @param {boolean} pref 用户是否手动折叠(只对 wide 档生效)
|
|
45
|
+
* @returns {{mode: 'wide'|'mid'|'drawer', collapsed: boolean, width: number, drawer: boolean}}
|
|
46
|
+
*/
|
|
47
|
+
export function sideNavLayout(width, pref) {
|
|
48
|
+
const mode = sideNavBreakpoint(width);
|
|
49
|
+
const collapsed = mode === 'mid' || (mode === 'wide' && !!pref);
|
|
50
|
+
return { mode, collapsed, width: mode === 'drawer' ? 0 : (collapsed ? 48 : 240), drawer: mode === 'drawer' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 错误卡三段式的纯计算:原文 / 处置建议 / 归属标签 / 首行摘要。
|
|
55
|
+
* 换行统一为 \n;处置建议行(💡 / 提示:/ 处置建议:)从原文中剥离;
|
|
56
|
+
* 原文为空时退回整段 trim 后的文本,head 始终取第一行。
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} text 上游错误原文
|
|
59
|
+
* @returns {{head: string, hint: string, raw: string, tag: '上游'|'内核'|'错误'}}
|
|
60
|
+
*/
|
|
61
|
+
export function errCardParts(text) {
|
|
62
|
+
const rawText = String(text || '').replace(/\r\n?/g, '\n');
|
|
63
|
+
const lines = rawText.split('\n');
|
|
64
|
+
let hintIdx = -1;
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
if (/^\s*(?:💡|提示[::]|处置建议[::])/.test(lines[i])) { hintIdx = i; break; }
|
|
67
|
+
}
|
|
68
|
+
const hint = hintIdx >= 0 ? lines[hintIdx].trim() : '';
|
|
69
|
+
const raw = (hintIdx >= 0 ? lines.slice(0, hintIdx) : lines).join('\n').trim() || rawText.trim();
|
|
70
|
+
const head = (lines[0] || '').trim();
|
|
71
|
+
const tag = /上游|upstream/i.test(rawText) ? '上游' : /超时|timeout/i.test(rawText) ? '内核' : '错误';
|
|
72
|
+
return { head, hint, raw, tag };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 运行阶段收敛成五档(等首帧 / 解码中 / 执行工具 / 已中止 / 空闲)。
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} rawPhase 服务端 phase 文案
|
|
79
|
+
* @param {boolean} isGenerating 是否生成中
|
|
80
|
+
* @param {boolean|undefined} aborted 是否已中止
|
|
81
|
+
* @returns {'等首帧'|'解码中'|'执行工具'|'已中止'|'空闲'}
|
|
82
|
+
*/
|
|
83
|
+
export function normalizeRunPhase(rawPhase, isGenerating, aborted) {
|
|
84
|
+
if (aborted) return '已中止';
|
|
85
|
+
if (!isGenerating) return '空闲';
|
|
86
|
+
const p = String(rawPhase || '');
|
|
87
|
+
if (/已中止|已停止|aborted|stalled/.test(p)) return '已中止';
|
|
88
|
+
if (/解码/.test(p)) return '解码中';
|
|
89
|
+
if (/执行工具|等待权限确认|待发请求|子\s?Agent|工具/.test(p)) return '执行工具';
|
|
90
|
+
return '等首帧';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 首帧耗时文案:非法/缺省显示 —。
|
|
95
|
+
*
|
|
96
|
+
* @param {unknown} ms 毫秒
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function fmtRunTtft(ms) {
|
|
100
|
+
return ms == null || !Number.isFinite(Number(ms)) ? '—' : (Number(ms) / 1000).toFixed(1) + 's';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 吞吐文案:非法/缺省显示 —。
|
|
105
|
+
*
|
|
106
|
+
* @param {unknown} v tokens/s
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function fmtRunTps(v) {
|
|
110
|
+
return v == null || !Number.isFinite(Number(v)) ? '—' : Number(v).toFixed(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 工具调用次数文案:0 也必须显式写出来。
|
|
115
|
+
*
|
|
116
|
+
* @param {unknown} n
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function fmtRunTools(n) {
|
|
120
|
+
const k = Math.max(0, Math.round(Number(n) || 0));
|
|
121
|
+
return k === 0 ? '0 次工具调用' : k + ' 次工具调用';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 费用只取 ≈¥ 金额部分(缓存命中/价格表提示不进一行状态条)。
|
|
126
|
+
*
|
|
127
|
+
* @param {unknown} cost
|
|
128
|
+
* @returns {string} 无金额时返回空串
|
|
129
|
+
*/
|
|
130
|
+
export function fmtRunCost(cost) {
|
|
131
|
+
const m = String(cost || '').match(/≈¥[0-9.]+/);
|
|
132
|
+
return m ? m[0] : '';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 上下文占用文案:无数据 —;生成中用 ≈ 标明估算;done 带回精确 budget 时不带 ≈。
|
|
137
|
+
*
|
|
138
|
+
* @param {{contextTotal?: unknown, contextUsed?: unknown, contextApprox?: boolean}} st
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function fmtRunCtx(st) {
|
|
142
|
+
const total = Number(st.contextTotal) || 0;
|
|
143
|
+
const used = Math.max(0, Math.round(Number(st.contextUsed) || 0));
|
|
144
|
+
if (!total && !used) return '—';
|
|
145
|
+
const approx = st.contextApprox !== false;
|
|
146
|
+
return (approx ? '≈ ' : '') + fmtTok(used) + ' / ' + (total ? fmtTok(total) : '?') + ' tokens';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 常驻运行状态条的六字段视图(状态条与右栏「本轮运行」同源)。
|
|
151
|
+
*
|
|
152
|
+
* @param {{rawPhase?: unknown, aborted?: boolean, ttftMs?: unknown, tokensPerSec?: unknown,
|
|
153
|
+
* toolCalls?: unknown, contextTotal?: unknown, contextUsed?: unknown,
|
|
154
|
+
* contextApprox?: boolean, cost?: unknown}} st
|
|
155
|
+
* @param {boolean} [isGenerating]
|
|
156
|
+
* @returns {{phase: string, ttft: string, tps: string, ctx: string, tools: string, cost: string}}
|
|
157
|
+
*/
|
|
158
|
+
export function runStripView(st, isGenerating) {
|
|
159
|
+
return {
|
|
160
|
+
phase: normalizeRunPhase(st.rawPhase, isGenerating !== false, st.aborted),
|
|
161
|
+
ttft: fmtRunTtft(st.ttftMs),
|
|
162
|
+
tps: fmtRunTps(st.tokensPerSec),
|
|
163
|
+
ctx: fmtRunCtx(st),
|
|
164
|
+
tools: fmtRunTools(st.toolCalls),
|
|
165
|
+
cost: fmtRunCost(st.cost) || '—',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 把一条 progress 事件并进运行状态(桩 SSE 与真实 SSE 走同一条路径)。
|
|
171
|
+
* 只改传入的 st(调用方持有的状态对象),不读 DOM/全局。
|
|
172
|
+
*
|
|
173
|
+
* @param {Record<string, any>} st
|
|
174
|
+
* @param {{phase?: unknown, ttftMs?: unknown, tokensPerSec?: unknown, toolCalls?: unknown, contentChars?: unknown}} ev
|
|
175
|
+
* @param {unknown} baseUsed 上一轮已知 used tokens
|
|
176
|
+
* @returns {Record<string, any>} 同一个 st
|
|
177
|
+
*/
|
|
178
|
+
export function applyRunProgress(st, ev, baseUsed) {
|
|
179
|
+
st.rawPhase = String(ev.phase || '');
|
|
180
|
+
st.phase = normalizeRunPhase(st.rawPhase, true, false);
|
|
181
|
+
if (ev.ttftMs != null) st.ttftMs = Number(ev.ttftMs) || 0;
|
|
182
|
+
st.tokensPerSec = ev.tokensPerSec != null ? Number(ev.tokensPerSec) : null;
|
|
183
|
+
st.toolCalls = Number(ev.toolCalls) || 0;
|
|
184
|
+
st.contentChars = Number(ev.contentChars) || 0;
|
|
185
|
+
st.contextApprox = true;
|
|
186
|
+
st.contextUsed = Math.max(0, Number(baseUsed) || 0) + Math.round(st.contentChars / 1.5);
|
|
187
|
+
return st;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 权限来源的人读标签。
|
|
192
|
+
*
|
|
193
|
+
* @param {unknown} source explicit / preset / config / cli / default
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
export function permissionSourceLabel(source) {
|
|
197
|
+
return ({ explicit: '显式选择', preset: '预设', config: 'config 默认', cli: '命令行', default: '内置默认' })[String(source || '')] || '未知';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// —— 工具时间线:同一轮多个调用聚合成一张卡(默认前 3 步 + 失败步,整卡可折叠) ——
|
|
201
|
+
const TOOL_TL_DEFAULT_VISIBLE = 3;
|
|
202
|
+
const TOOL_OUTPUT_PREVIEW = 900;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 工具参数的一行摘要(完整参数在展开区里用 JSON 显示)。
|
|
206
|
+
*
|
|
207
|
+
* @param {unknown} name 工具名
|
|
208
|
+
* @param {Record<string, any>} [args]
|
|
209
|
+
* @returns {string}
|
|
210
|
+
*/
|
|
211
|
+
export function toolArgsSummary(name, args) {
|
|
212
|
+
const a = args || {};
|
|
213
|
+
if (name === 'bash') return String(a.command || '');
|
|
214
|
+
if (name === 'task') return String(a.question || a.prompt || a.description || '');
|
|
215
|
+
return String(a.path || a.pattern || a.name || a.query || a.url || a.file || '');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 工具结果的一行摘要(不是正文;正文在展开区)。
|
|
220
|
+
*
|
|
221
|
+
* @param {any} ev 工具事件
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
export function toolResultSummary(ev) {
|
|
225
|
+
if (!ev || ev.result === undefined) return '执行中…';
|
|
226
|
+
const r = ev.result || {};
|
|
227
|
+
if (r.ok === false) return r.error ? String(r.error).split('\n')[0] : '失败';
|
|
228
|
+
if (ev.name === 'bash') return r.timedOut ? '超时' : 'exit ' + (r.exitCode != null ? r.exitCode : 0);
|
|
229
|
+
if (ev.name === 'skill') {
|
|
230
|
+
const m = String(r.output || '').match(/^description:\s*(.+)$/m);
|
|
231
|
+
return m ? m[1].slice(0, 100) : '已加载技能';
|
|
232
|
+
}
|
|
233
|
+
if (ev.name === 'write') return r.bytes != null ? r.bytes + ' 字节' : '已写入';
|
|
234
|
+
if (ev.name === 'edit') return '已修改';
|
|
235
|
+
if (ev.name === 'read') return r.lines != null ? r.lines + ' 行' : '读取完成';
|
|
236
|
+
if (ev.name === 'todo') return '更新任务清单';
|
|
237
|
+
const out = String(r.output || r.stdout || r.stderr || '');
|
|
238
|
+
return out.split('\n')[0].slice(0, 120);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 工具输出的完整文本(展开区用)。
|
|
243
|
+
*
|
|
244
|
+
* @param {any} ev 工具事件
|
|
245
|
+
* @returns {string}
|
|
246
|
+
*/
|
|
247
|
+
export function toolOutputText(ev) {
|
|
248
|
+
const r = (ev && ev.result) || {};
|
|
249
|
+
if (ev && ev.name === 'skill') return String(r.output || '');
|
|
250
|
+
if (r.diff) {
|
|
251
|
+
const before = String(r.diff.before || '').split('\n');
|
|
252
|
+
const after = String(r.diff.after || '').split('\n');
|
|
253
|
+
const n = Math.max(before.length, after.length);
|
|
254
|
+
const out = [];
|
|
255
|
+
for (let i = 0; i < n; i++) {
|
|
256
|
+
if (before[i] === after[i]) out.push(' ' + (before[i] || ''));
|
|
257
|
+
else {
|
|
258
|
+
if (before[i] !== undefined) out.push('- ' + before[i]);
|
|
259
|
+
if (after[i] !== undefined) out.push('+ ' + after[i]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out.join('\n');
|
|
263
|
+
}
|
|
264
|
+
if (ev && ev.name === 'bash') return [r.stdout, r.stderr].filter(Boolean).join('\n') || ('exit ' + (r.exitCode != null ? r.exitCode : 0));
|
|
265
|
+
if (r.output != null) return String(r.output);
|
|
266
|
+
if (r.error != null) return String(r.error);
|
|
267
|
+
return ev && ev.result !== undefined ? JSON.stringify(r, null, 2) : '';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 长输出折叠判据:给出完整文本、预览、行数与字节数(用户要知道被折了多少)。
|
|
272
|
+
*
|
|
273
|
+
* @param {unknown} text
|
|
274
|
+
* @param {unknown} [limit]
|
|
275
|
+
* @returns {{full: string, preview: string, lines: number, bytes: number, truncated: boolean}}
|
|
276
|
+
*/
|
|
277
|
+
export function toolOutputStats(text, limit) {
|
|
278
|
+
const full = String(text == null ? '' : text);
|
|
279
|
+
const max = Number(limit) || TOOL_OUTPUT_PREVIEW;
|
|
280
|
+
const lines = full === '' ? 0 : full.split('\n').length;
|
|
281
|
+
let bytes = full.length;
|
|
282
|
+
try { bytes = new TextEncoder().encode(full).length; } catch { bytes = full.length; }
|
|
283
|
+
const truncated = full.length > max || lines > 40;
|
|
284
|
+
const preview = !truncated ? full : (full.length > max ? full.slice(0, max) : full.split('\n').slice(0, 40).join('\n'));
|
|
285
|
+
return { full, preview, lines, bytes, truncated };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* 单步视图(失败高亮类名 / 状态图标 / 一行摘要 / 完整参数与输出)。
|
|
290
|
+
*
|
|
291
|
+
* @param {any} ev 工具事件
|
|
292
|
+
* @param {boolean} [done] 覆盖完成判据
|
|
293
|
+
* @returns {Record<string, any>}
|
|
294
|
+
*/
|
|
295
|
+
export function toolStepView(ev, done) {
|
|
296
|
+
const isDone = done !== undefined ? Boolean(done) : ev.result !== undefined || ev.denied;
|
|
297
|
+
const failed = Boolean(ev.denied) || (ev.result !== undefined && ev.result && ev.result.ok === false);
|
|
298
|
+
const out = toolOutputStats(toolOutputText(ev));
|
|
299
|
+
return {
|
|
300
|
+
seq: ev.seq,
|
|
301
|
+
name: String(ev.name || 'tool'),
|
|
302
|
+
args: toolArgsSummary(ev.name, ev.args),
|
|
303
|
+
argsJson: JSON.stringify(ev.args || {}, null, 2),
|
|
304
|
+
durationMs: ev.durationMs != null ? Number(ev.durationMs) : null,
|
|
305
|
+
dur: ev.durationMs != null ? fmtToolDur(ev.durationMs) : (isDone ? '—' : '执行中…'),
|
|
306
|
+
done: isDone,
|
|
307
|
+
failed,
|
|
308
|
+
stIcon: !isDone ? '…' : failed ? '✖' : '✓',
|
|
309
|
+
stCls: !isDone ? '' : failed ? 'bad' : 'ok',
|
|
310
|
+
result: ev.denied ? String(ev.denied || '未授权') : toolResultSummary(ev),
|
|
311
|
+
output: out,
|
|
312
|
+
liClass: 'tl-step' + (failed ? ' fail' : ''),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 工具耗时文案:<1s 用 ms,其余保留一位小数秒。
|
|
318
|
+
*
|
|
319
|
+
* @param {unknown} ms
|
|
320
|
+
* @returns {string}
|
|
321
|
+
*/
|
|
322
|
+
export function fmtToolDur(ms) {
|
|
323
|
+
const n = Number(ms) || 0;
|
|
324
|
+
return n < 1000 ? n + 'ms' : (n / 1000).toFixed(1) + 's';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 时间线汇总:步数 / 失败数 / 总耗时 / 头行文案。
|
|
329
|
+
*
|
|
330
|
+
* @param {Array<{failed?: boolean, durationMs?: unknown}>} [items]
|
|
331
|
+
* @returns {{count: number, failed: number, totalMs: number, countText: string, sumText: string}}
|
|
332
|
+
*/
|
|
333
|
+
export function toolTimelineSummary(items) {
|
|
334
|
+
const list = items || [];
|
|
335
|
+
const failed = list.filter((s) => s.failed).length;
|
|
336
|
+
const totalMs = list.reduce((a, s) => a + (Number(s.durationMs) || 0), 0);
|
|
337
|
+
const detail = list.length + ' 步' + (failed ? ' · ' + failed + ' 个失败' : '');
|
|
338
|
+
return {
|
|
339
|
+
count: list.length,
|
|
340
|
+
failed,
|
|
341
|
+
totalMs,
|
|
342
|
+
countText: detail,
|
|
343
|
+
sumText: totalMs ? fmtToolDur(totalMs) : '',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 默认可见步:前 3 步 + 所有失败步(失败步免展开可见)。
|
|
349
|
+
*
|
|
350
|
+
* @param {Array<{failed?: boolean}>} [items]
|
|
351
|
+
* @param {boolean} [expanded]
|
|
352
|
+
* @returns {number[]}
|
|
353
|
+
*/
|
|
354
|
+
export function toolTimelineVisibility(items, expanded) {
|
|
355
|
+
const list = items || [];
|
|
356
|
+
if (expanded) return list.map((_, i) => i);
|
|
357
|
+
const keep = new Set();
|
|
358
|
+
for (let i = 0; i < Math.min(TOOL_TL_DEFAULT_VISIBLE, list.length); i++) keep.add(i);
|
|
359
|
+
list.forEach((s, i) => { if (s.failed) keep.add(i); });
|
|
360
|
+
return [...keep].sort((a, b) => a - b);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 轨迹耗时文案:<60s 用秒;<60m 用 m/s;否则 h/m。
|
|
365
|
+
*
|
|
366
|
+
* @param {unknown} ms
|
|
367
|
+
* @returns {string}
|
|
368
|
+
*/
|
|
369
|
+
export function fmtTraceDur(ms) {
|
|
370
|
+
const total = Math.max(0, Math.round((Number(ms) || 0) / 1000));
|
|
371
|
+
if (total < 60) return total + 's';
|
|
372
|
+
const m = Math.floor(total / 60);
|
|
373
|
+
const s = total % 60;
|
|
374
|
+
if (m < 60) return m + 'm' + (s ? s + 's' : '');
|
|
375
|
+
const h = Math.floor(m / 60);
|
|
376
|
+
const rm = m % 60;
|
|
377
|
+
return h + 'h' + (rm ? rm + 'm' : '');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 子代理一行的视图:名称 · 轮次 · 工具次数 · 最后进展 · 状态(缺失字段如实显示 —,不编造)。
|
|
382
|
+
*
|
|
383
|
+
* @param {any} sub 子代理快照
|
|
384
|
+
* @returns {Record<string, any>}
|
|
385
|
+
*/
|
|
386
|
+
export function subagentRowView(sub) {
|
|
387
|
+
const s = sub || {};
|
|
388
|
+
const toolCalls = s.toolCalls == null || !Number.isFinite(Number(s.toolCalls)) ? null : Math.max(0, Math.round(Number(s.toolCalls)));
|
|
389
|
+
const modelRounds = s.modelRounds == null || !Number.isFinite(Number(s.modelRounds)) ? null : Math.max(0, Math.round(Number(s.modelRounds)));
|
|
390
|
+
const ago = s.lastProgressAgoMs == null || !Number.isFinite(Number(s.lastProgressAgoMs)) ? null : Math.max(0, Math.round(Number(s.lastProgressAgoMs)));
|
|
391
|
+
const running = Boolean(s.running);
|
|
392
|
+
const failed = !running && (s.status === 'failed' || Boolean(s.result && s.result.ok === false));
|
|
393
|
+
const status = running ? 'running' : failed ? 'failed' : 'done';
|
|
394
|
+
const round = Math.max(0, Math.round(Number(s.round) || 0));
|
|
395
|
+
return {
|
|
396
|
+
key: 'sub:' + String(s.seq == null ? '?' : s.seq),
|
|
397
|
+
seq: s.seq,
|
|
398
|
+
round,
|
|
399
|
+
roundText: round ? '第 ' + round + ' 轮' : '—',
|
|
400
|
+
name: String(s.label || s.description || s.question || '子代理').slice(0, 80),
|
|
401
|
+
question: String(s.question || ''),
|
|
402
|
+
toolCalls,
|
|
403
|
+
toolText: toolCalls == null ? '工具次数 —' : toolCalls + ' 次工具',
|
|
404
|
+
modelRounds,
|
|
405
|
+
roundsText: modelRounds == null ? '自身轮次 —' : modelRounds + ' 轮',
|
|
406
|
+
progress: ago == null ? '最后进展 —' : '最后进展 ' + (ago < 1000 ? '<1s' : Math.round(ago / 1000) + 's') + ' 前',
|
|
407
|
+
status,
|
|
408
|
+
statusText: running ? '运行中' : failed ? '失败' : '完成',
|
|
409
|
+
statusCls: running ? 'run' : failed ? 'bad' : 'ok',
|
|
410
|
+
running,
|
|
411
|
+
durationMs: s.durationMs == null ? null : Number(s.durationMs),
|
|
412
|
+
result: s.result,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 一轮的完整视图(工具步 + 子代理 + 摘要文案);对话流与右栏消费同一个对象。
|
|
418
|
+
*
|
|
419
|
+
* @param {{now?: unknown, round?: unknown, startedAt?: unknown, endedAt?: unknown,
|
|
420
|
+
* tools?: any[], subs?: any[]}} [round]
|
|
421
|
+
* @returns {{round: number, tools: any[], subs: any[], toolCount: number, subCount: number,
|
|
422
|
+
* failed: number, durationMs: number, durationText: string, summary: string}}
|
|
423
|
+
*/
|
|
424
|
+
export function traceRoundView(round) {
|
|
425
|
+
const r = round || {};
|
|
426
|
+
const now = Number(r.now) || Date.now();
|
|
427
|
+
const tools = (r.tools || []).map((t) => toolStepView(t, t.done));
|
|
428
|
+
const subs = (r.subs || []).map(subagentRowView);
|
|
429
|
+
const started = Number(r.startedAt) || 0;
|
|
430
|
+
const ended = r.endedAt != null ? Number(r.endedAt) : now;
|
|
431
|
+
const durationMs = started ? Math.max(0, ended - started) : 0;
|
|
432
|
+
const durationText = started ? fmtTraceDur(durationMs) : '';
|
|
433
|
+
const parts = ['本轮 ' + tools.length + ' 次工具调用'];
|
|
434
|
+
if (subs.length) parts.push(subs.length + ' 个子代理');
|
|
435
|
+
if (durationText) parts.push(durationText);
|
|
436
|
+
return {
|
|
437
|
+
round: Math.max(0, Math.round(Number(r.round) || 0)),
|
|
438
|
+
tools,
|
|
439
|
+
subs,
|
|
440
|
+
toolCount: tools.length,
|
|
441
|
+
subCount: subs.length,
|
|
442
|
+
failed: tools.filter((t) => t.failed).length,
|
|
443
|
+
durationMs,
|
|
444
|
+
durationText,
|
|
445
|
+
summary: parts.join(' · '),
|
|
446
|
+
};
|
|
447
|
+
}
|