@migor/cli 0.10.8 → 0.10.10
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/CHANGELOG.md +115 -1
- package/dist/dev-machine.js +8 -1
- package/dist/inspector/fromTrace.js +64 -37
- package/dist/inspector/full-view.js +49 -20
- package/dist/inspector/trace-view.bundle.js +362 -69
- package/dist/inspector/trace-view.css +66 -0
- package/dist/inspector/view.js +249 -12
- package/dist/inspector-page.html +626 -47
- package/dist/inspector-routes.js +4 -1
- package/dist/markdown.js +170 -6
- package/dist/panel-logic.js +275 -8
- package/dist/templates/package.json +2 -2
- package/package.json +1 -1
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
* 序列化后又塞回 `content` 的工具 —— 这些值的类型是**字符串**,此前只显示成一串
|
|
26
26
|
* `{\"a\":1` 的反斜杠汤,比无缩进的 JSON 更难读。现在首尾是 `{`…`}` / `[`…`]` 且解析成功的
|
|
27
27
|
* 字符串按容器铺开,并在行里打 `str→json` 标记 —— 不标出来,读者会以为工具真的返回了对象。
|
|
28
|
+
* 5. **一整篇 Markdown 的字符串正文**(2026-11-12 晚):工具出参的另一种常见形态就是一份
|
|
29
|
+
* SKILL.md / 错误汇总报告,类型仍是字符串 ⇒ 展开后是 `# 标题`、`|---|---|`、
|
|
30
|
+
* `<!-- 迁移自 … -->` 的源文。本件**不**自带解析器 —— 那会是仓库里的第二份,必然与宿主
|
|
31
|
+
* 那份漂移,所以只接受宿主渲染好的元素(`opts.body`),自己负责头部
|
|
32
|
+
* (`string · N 字符 · md` + `md` 标记)与「复制取原文」。
|
|
28
33
|
*
|
|
29
34
|
* ## 边界(都是「不这么做就会坏」的地方,不是口味)
|
|
30
35
|
*
|
|
@@ -49,6 +54,8 @@
|
|
|
49
54
|
* 数字/空值/布尔就是**改写工具返回的事实**。解析结果内部的字符串**不再二次解析**:
|
|
50
55
|
* 一层套一层是业务里的真实现象(SSE data 里再套 JSON),逐层展开会让 10KB 变成十万个节点。
|
|
51
56
|
* 表格单元格也不解析 —— 同构表已经放弃了逐层展开,单元格里放不下嵌套块。
|
|
57
|
+
* - **宿主正文不再解析**:`opts.body` 给了就**原样用**。同一段内容既渲染又铺树,读者会看到
|
|
58
|
+
* 两套互相矛盾的呈现 —— 那比不做这一档更糟。所以这条判据只有一处(`createFullView`)。
|
|
52
59
|
*
|
|
53
60
|
* 零依赖、纯 DOM 构造(只用 createElement / appendChild / textContent / className /
|
|
54
61
|
* dataset / innerHTML=''),与 view.js 共用同一套 DOM stub 跑测试。
|
|
@@ -175,6 +182,14 @@ function jsonBadge() {
|
|
|
175
182
|
return b;
|
|
176
183
|
}
|
|
177
184
|
|
|
185
|
+
/** 「这段正文被渲染过」的标记。同样**必须显眼**:Markdown 源文与渲染结果差得很远,
|
|
186
|
+
* 不标出来,读者会以为工具返回的就是排好版的样子(而它返回的是一串 `#` 与 `|`)。 */
|
|
187
|
+
function mdBadge() {
|
|
188
|
+
const b = fvEl('span', 'tr-full-md', 'md');
|
|
189
|
+
b.title = '这个字符串按 Markdown 渲染(原文仍在 .tr-io 与「复制」里,一字未改)';
|
|
190
|
+
return b;
|
|
191
|
+
}
|
|
192
|
+
|
|
178
193
|
/** 子键的 JSON 路径:对象用 `.key`,数组用 `[i]`。根是空串,所以 `[0]` 不会变成 `.[0]` */
|
|
179
194
|
function pathOf(parent, key, isArr) {
|
|
180
195
|
return isArr ? `${parent}[${key}]` : parent ? `${parent}.${key}` : key;
|
|
@@ -406,48 +421,62 @@ function renderInto(ctx, v, depth, path, allowParse = true) {
|
|
|
406
421
|
/**
|
|
407
422
|
* 建一个结构化正文块。
|
|
408
423
|
* @param {any} value `ev.full` 的**原值**(不是序列化后的字符串)
|
|
409
|
-
* @param {{fold?: Map<string, boolean
|
|
424
|
+
* @param {{fold?: Map<string, boolean>, body?: Element}} [opts]
|
|
410
425
|
* `fold` 是**调用方持有**的折叠状态(路径 → 是否展开)。省略时本块自建一份,
|
|
411
426
|
* 此时折叠状态活不过下一次「全量重建」—— 只对一次性渲染(测试、静态快照)够用。
|
|
427
|
+
* `body` 是**宿主已经渲染好的正文**(如 Markdown):给了就原样用,不再走缩进/折叠,
|
|
428
|
+
* 本件只负责头部(类型 / `md` 标记 / 复制)与挂载位置。
|
|
412
429
|
* @returns {Element} `.tr-full` 块;调用方负责 append 到展开行之后
|
|
413
430
|
*/
|
|
414
431
|
function createFullView(value, opts = {}) {
|
|
415
432
|
const fold = opts.fold instanceof Map ? opts.fold : new Map();
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
433
|
+
/* 宿主渲染好的正文。**判据只此一处**:给了它就不解析、不铺树 —— 否则会同时出现
|
|
434
|
+
两套呈现(宿主渲染的正文 + 本件又在旁边铺一遍原值),那比不渲染更糟。 */
|
|
435
|
+
const injected = opts.body && typeof opts.body === 'object' ? opts.body : null;
|
|
436
|
+
|
|
437
|
+
// 根是字符串 ⇒ 只有「里面装着 JSON」或「宿主认得的正文」才会进来。
|
|
438
|
+
// 头部同时写出**两件事**:它原本是字符串(字符数)、以及它是怎么被展示的 —— 只写后者,
|
|
439
|
+
// 读者会以为工具真的返回了对象 / 排好版的正文。
|
|
440
|
+
const parsedRoot = injected ? null : jsonInString(value);
|
|
421
441
|
const root = parsedRoot ?? value;
|
|
422
442
|
const fromJson = parsedRoot !== null;
|
|
423
443
|
|
|
424
444
|
const box = fvEl('div', 'tr-full');
|
|
425
|
-
box.dataset.kind = Array.isArray(root) ? 'array' : 'object';
|
|
445
|
+
box.dataset.kind = injected ? 'md' : Array.isArray(root) ? 'array' : 'object';
|
|
426
446
|
|
|
427
447
|
const head = fvEl('div', 'tr-full-head');
|
|
428
448
|
const label = typeLabelOf(root);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
449
|
+
let typeText = label;
|
|
450
|
+
if (fromJson) typeText = `string · ${value.length} 字符 → ${label}`;
|
|
451
|
+
// 注入正文时不写「→ md」:紧跟其后的 `md` 标记已经说了这件事,
|
|
452
|
+
// 同一行说两遍只会让人以为其中一个另有含义(字符数才是头部真正要补的信息)
|
|
453
|
+
else if (injected) typeText = `string · ${value.length} 字符`;
|
|
454
|
+
head.appendChild(fvEl('span', 'tr-full-type', typeText));
|
|
432
455
|
if (fromJson) head.appendChild(jsonBadge());
|
|
456
|
+
if (injected) head.appendChild(mdBadge());
|
|
433
457
|
const copy = fvEl('span', 'tr-full-copy', '复制');
|
|
434
458
|
copy.dataset.copy = '1';
|
|
435
|
-
// 字符串根复制的是**原文**(拿到手能直接喂 jq
|
|
459
|
+
// 字符串根复制的是**原文**(拿到手能直接喂 jq / 贴回上游);容器根复制的是缩进后的 JSON
|
|
436
460
|
copy.addEventListener('click', () =>
|
|
437
|
-
copyInto(copy, fromJson ? String(value) : safeStringify(root), '已复制'),
|
|
461
|
+
copyInto(copy, fromJson || injected ? String(value) : safeStringify(root), '已复制'),
|
|
438
462
|
);
|
|
439
463
|
head.appendChild(copy);
|
|
440
464
|
box.appendChild(head);
|
|
441
465
|
|
|
442
466
|
const body = fvEl('div', 'tr-full-body');
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
467
|
+
if (injected) {
|
|
468
|
+
// 宿主正文自带排版;本件的缩进/折叠那一套对散文没有意义(它也没有「路径」可复制)
|
|
469
|
+
body.appendChild(injected);
|
|
470
|
+
} else {
|
|
471
|
+
const ctx = { body, fold, repaint: null };
|
|
472
|
+
// 只重画正文(不动头部):折叠不需要重建整棵树,滚动位置与头部反馈都留着
|
|
473
|
+
ctx.repaint = () => {
|
|
474
|
+
body.innerHTML = '';
|
|
475
|
+
// 根若是从字符串解析来的,它的子树不再二次解析(只解析一层,防嵌套放大)
|
|
476
|
+
renderInto(ctx, root, 0, '', !fromJson);
|
|
477
|
+
};
|
|
478
|
+
ctx.repaint();
|
|
479
|
+
}
|
|
451
480
|
|
|
452
481
|
box.appendChild(body);
|
|
453
482
|
return box;
|
|
@@ -473,11 +502,14 @@ function createFullView(value, opts = {}) {
|
|
|
473
502
|
* 宿主需提供的 CSS 变量:--ice --faint --text(见 trace-view.css)。
|
|
474
503
|
*/
|
|
475
504
|
|
|
476
|
-
/*
|
|
505
|
+
/* 展开态的正文有三种形状(2026-11-11,2026-11-12 扩了两档):
|
|
477
506
|
* - 纯文本字符串 → 老路:`.tr-io` 里放原文,靠 `.tr-open` 换行(纯文本正文、官网模拟回放);
|
|
478
507
|
* - 对象/数组 → 追加一个 `.tr-full` 结构化块(折叠 / 成表 / 路径复制),`.tr-io` 由 CSS 隐藏;
|
|
479
508
|
* - **装着 JSON 的字符串**(`content: "{\"rows\":[...]}"`,工具把结构序列化后塞回文本字段)
|
|
480
|
-
* → 同上一档,块头写明「string · N 字符 → object · M 项」并打 `str→json`
|
|
509
|
+
* → 同上一档,块头写明「string · N 字符 → object · M 项」并打 `str→json` 标记;
|
|
510
|
+
* - **一整篇 Markdown 的字符串**(工具出参就是一份 SKILL.md / 报告)→ 由宿主注入的
|
|
511
|
+
* `opts.textBody(text)` 渲染成正文块,块头打 `md` 标记。注入是刻意的:本包是独立
|
|
512
|
+
* 发布单元,解析器只有一份(在宿主里),两边各带一份必然漂移。
|
|
481
513
|
* 判据与块内完全同源(`hasStructuredBody`),「挂不挂块」与「块里画什么」因此不会跑偏。
|
|
482
514
|
* 前两档并存是刻意的:纯文本那条路有 test/style.test.js 的两个不变量守着(最小宽度、caret 常显),
|
|
483
515
|
* 全换成本件会让那两个守卫失去落点。 */
|
|
@@ -547,10 +579,31 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
547
579
|
let price = opts.price ?? null;
|
|
548
580
|
const onUsage = opts.onUsage ?? null;
|
|
549
581
|
const usageEls = opts.usage ?? null;
|
|
582
|
+
/* 字符串正文的宿主渲染器(可选):`(text) => Element | null`。返回 null = 「不是那种
|
|
583
|
+
正文,保持原文」。本包不自带 Markdown 解析器 —— 见文件头注。 */
|
|
584
|
+
const textBody = typeof opts.textBody === 'function' ? opts.textBody : null;
|
|
550
585
|
|
|
551
586
|
let traceRoot = null;
|
|
552
587
|
const spanMap = new Map();
|
|
553
588
|
const usageAcc = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
|
|
589
|
+
/* 批量渲染(2026-11-13)。此前每来一条 span 动作就整树重建一次 ⇒ `playTrace` 是 O(N²):
|
|
590
|
+
实测 50 span 触发 151 次全树重建、600 span 触发 1801 次(假 DOM 就 172ms),而在飞时
|
|
591
|
+
`renderLive` 每个 rAF 还会再调一次 `playTrace`。这不是「慢一点」,是长跑时面板直接卡死。
|
|
592
|
+
语义不变:**渲染仍是全量重建**(那是折叠态必须外部持有的原因),只是把「一批动作之间
|
|
593
|
+
的 N 次重建」压成「结束后一次」。
|
|
594
|
+
⚠️ 嵌套是安全的(计数器),且批内抛错也会在 finally 里补画 —— 否则异常路径之后整棵树
|
|
595
|
+
停在中间状态,而下一条事件又会被 `dirty` 吞掉。 */
|
|
596
|
+
let batchDepth = 0;
|
|
597
|
+
let batchDirty = false;
|
|
598
|
+
/* 树内检索(2026-11-13):**不改结构**,只给命中的行加类 + 提供「跳到下一处」。
|
|
599
|
+
为什么不裁剪节点:树形是「谁挂在谁下面」的事实,隐藏父节点会让子节点看起来属于别人;
|
|
600
|
+
而裁剪后「有多少条被藏起来了」又要另一处交代。高亮 + 跳转在不改事实的前提下解决
|
|
601
|
+
「一百个 span 里找那一条」。 */
|
|
602
|
+
let query = '';
|
|
603
|
+
let hitIndex = -1;
|
|
604
|
+
/* 时间轴开关(2026-11-13):轨道占 72px,窄面板里是从名字里抢的 ⇒ 允许关掉。
|
|
605
|
+
默认开 —— 它是「谁和谁并行 / 8 秒被谁吃掉」唯一的落点,而那条信息此前完全没有。 */
|
|
606
|
+
let timeline = true;
|
|
554
607
|
/* 展开态的行 key 集合 + 事件 key 发号器。两者都必须活在 renderTrace 之外:
|
|
555
608
|
渲染是【全量重建】(rootEl.innerHTML = ''),状态放进 DOM 或渲染过程里就活不过下一次事件。 */
|
|
556
609
|
const expanded = new Set();
|
|
@@ -574,8 +627,66 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
574
627
|
renderTrace();
|
|
575
628
|
}
|
|
576
629
|
|
|
630
|
+
/** 宿主渲染器包一层:**只在展开时调用**(折叠态每条事件都会重画,挂在那儿等于白解析
|
|
631
|
+
* 一遍所有历史正文);宿主抛错也不许让整棵树白屏 —— 回落原文即可。 */
|
|
632
|
+
function textBodyOf(v) {
|
|
633
|
+
if (!textBody || typeof v !== 'string') return null;
|
|
634
|
+
try {
|
|
635
|
+
return textBody(v) || null;
|
|
636
|
+
} catch {
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** 把一批渲染动作压成一次重画(见 `batchDepth` 的头注)。没在批里就直接画。 */
|
|
577
642
|
function renderTrace() {
|
|
643
|
+
if (batchDepth > 0) {
|
|
644
|
+
batchDirty = true;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
drawTree();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function batch(fn) {
|
|
651
|
+
batchDepth += 1;
|
|
652
|
+
try {
|
|
653
|
+
fn();
|
|
654
|
+
} finally {
|
|
655
|
+
batchDepth -= 1;
|
|
656
|
+
// 批内一次都没标脏(例如只读了状态)⇒ 不白画一遍
|
|
657
|
+
if (batchDepth === 0 && batchDirty) {
|
|
658
|
+
batchDirty = false;
|
|
659
|
+
drawTree();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** 检索:这一行的文本是否命中。空查询恒 false(「没在搜」不等于「全都命中」) */
|
|
665
|
+
function hits(texts) {
|
|
666
|
+
if (!query) return false;
|
|
667
|
+
const q = query.toLowerCase();
|
|
668
|
+
for (const t of texts) if (t && String(t).toLowerCase().includes(q)) return true;
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function drawTree() {
|
|
578
673
|
rootEl.innerHTML = '';
|
|
674
|
+
hitIndex = -1;
|
|
675
|
+
// 还没 reset 过(视图刚建出来):清空即返回。setTimeline / setQuery 可能先于第一棵树
|
|
676
|
+
// 被调用 —— 没有这一条,`traceRoot.order` 会抛,而调用方只是在设一个开关。
|
|
677
|
+
if (!traceRoot) return;
|
|
678
|
+
/* 时间轴的归一化基准:根 span 的起点与整棵树的终点。两者缺一就**不画条**
|
|
679
|
+
(手写 trace / 官网模拟回放没有时间戳 —— 画一条人人占满的条比不画更误导)。 */
|
|
680
|
+
const t0 = traceRoot && typeof traceRoot.startedAt === 'number' ? traceRoot.startedAt : null;
|
|
681
|
+
let tMax = t0;
|
|
682
|
+
if (t0 !== null) {
|
|
683
|
+
for (const n of spanMap.values()) {
|
|
684
|
+
const e = typeof n.endAt === 'number' ? n.endAt : n.startedAt;
|
|
685
|
+
if (typeof e === 'number' && e > tMax) tMax = e;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const total = t0 !== null && tMax !== null ? Math.max(0, tMax - t0) : 0;
|
|
689
|
+
|
|
579
690
|
const rows = [];
|
|
580
691
|
/* 每个节点下【事件】与【子 span】按发生顺序混排:tool.input 先于它触发的 capability span、
|
|
581
692
|
tool.output 后于它,这个先后本身就是语义,不能拍平成一类。 */
|
|
@@ -598,6 +709,11 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
598
709
|
const open = expanded.has(ev.key);
|
|
599
710
|
const row = el('div', 'tr-row tr-ev' + (ev.ok === false ? ' error' : ''));
|
|
600
711
|
row.dataset.ev = ev.type;
|
|
712
|
+
/* 检索只扫摘要与类型/工具名,**不扫完整正文**:`full` 常见 10KB 级,每帧每行
|
|
713
|
+
indexOf 一遍会让「搜索」本身成为掉帧的原因(600 行 × 10KB ≈ 6MB/帧)。
|
|
714
|
+
2000 字符以内才扫 —— 那个尺度上全文搜索才有意义(再长就是文档,不是一行)。 */
|
|
715
|
+
const fullShort = typeof ev.full === 'string' && ev.full.length <= 2000 ? ev.full : '';
|
|
716
|
+
if (hits([ev.type, ev.tool, ev.text, fullShort])) row.className += ' tr-hit';
|
|
601
717
|
row.dataset.capability = capabilityTypeOf(ev.tool);
|
|
602
718
|
row.appendChild(el('span', 'tr-pre', branch));
|
|
603
719
|
row.appendChild(el('span', 'tr-evv', ev.type === 'tool.output' ? '◂' : '▸'));
|
|
@@ -606,11 +722,13 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
606
722
|
// 这一格整个不渲染,别把「无工具」显示成假工具名(.tr-name 是 flex:none,.tr-io 自然占满)
|
|
607
723
|
if (ev.tool) row.appendChild(el('span', 'tr-name', ev.tool));
|
|
608
724
|
// 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致。
|
|
609
|
-
// `full`
|
|
610
|
-
// 仍放**原文**(结构化块挂在行后、由 CSS
|
|
611
|
-
// 62 字符摘要」这条不变量原样成立。
|
|
725
|
+
// `full` 是对象/数组、装着 JSON 的字符串、或宿主认得的正文(Markdown)时
|
|
726
|
+
// (2026-11-11 / 2026-11-12):`.tr-io` 仍放**原文**(结构化块挂在行后、由 CSS 隐藏本格),
|
|
727
|
+
// 所以「展开拿到的是原文而不是 62 字符摘要」这条不变量原样成立。
|
|
612
728
|
const structured = hasStructuredBody(ev.full);
|
|
613
|
-
const
|
|
729
|
+
const mdBody = open && !structured ? textBodyOf(ev.full) : null;
|
|
730
|
+
const blocked = structured || mdBody !== null;
|
|
731
|
+
const fullText = blocked ? rawArg(ev.full) : ev.full || '';
|
|
614
732
|
const io = el('span', 'tr-io', (open ? fullText : ev.text) || '');
|
|
615
733
|
// 展开后正文已在行里,再挂一个占满整行的原生 tooltip 只会挡视线
|
|
616
734
|
io.title = open ? '' : ev.text || '';
|
|
@@ -618,7 +736,7 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
618
736
|
if (ev.full) {
|
|
619
737
|
row.dataset.expandable = '1'; // 可展开标记:CSS 靠它出 caret / cursor,测试靠它找行
|
|
620
738
|
row.appendChild(el('span', 'tr-caret', open ? '▾' : '▸'));
|
|
621
|
-
if (open) row.className += ' tr-open' + (
|
|
739
|
+
if (open) row.className += ' tr-open' + (blocked ? ' tr-structured' : '');
|
|
622
740
|
row.addEventListener('click', () => {
|
|
623
741
|
if (!hasSelection()) toggleExpand(ev.key);
|
|
624
742
|
});
|
|
@@ -627,13 +745,15 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
627
745
|
// 结构化正文是**兄弟块**而不是 `.tr-io` 的子节点:行是 flex + nowrap,
|
|
628
746
|
// 塞进去会把树的列对齐打乱;独立成块才能自己换行、自己缩进。
|
|
629
747
|
// 折叠状态按事件键取一份带进去(2026-11-11 晚):块每次重建都 new,状态不能放块里。
|
|
630
|
-
|
|
748
|
+
// `body` 是宿主渲染好的正文(Markdown),交给同一条挂载路径:块头、复制、`.tr-io`
|
|
749
|
+
// 隐藏这些行为必须一致,否则「哪种正文」会从**外观**上分叉成两套。
|
|
750
|
+
if (open && blocked) {
|
|
631
751
|
let fold = folds.get(ev.key);
|
|
632
752
|
if (!fold) {
|
|
633
753
|
fold = new Map();
|
|
634
754
|
folds.set(ev.key, fold);
|
|
635
755
|
}
|
|
636
|
-
rootEl.appendChild(createFullView(ev.full, { fold }));
|
|
756
|
+
rootEl.appendChild(createFullView(ev.full, { fold, body: mdBody }));
|
|
637
757
|
}
|
|
638
758
|
return;
|
|
639
759
|
}
|
|
@@ -643,6 +763,18 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
643
763
|
row.dataset.kind = node.kind;
|
|
644
764
|
const ut = node.kind === 'capability' ? capabilityTypeOf(node.name) : '';
|
|
645
765
|
if (ut) row.dataset.capability = ut;
|
|
766
|
+
const attrKeys = node.attributes ? Object.keys(node.attributes) : [];
|
|
767
|
+
const attrKey = 'sp:' + node.id;
|
|
768
|
+
const attrOpen = attrKeys.length > 0 && expanded.has(attrKey);
|
|
769
|
+
/* 命中面:名字 / 入参摘要 / 错误原文 / **已展开的**属性值。
|
|
770
|
+
属性只在展开时才算:折叠态那些值本来就没显示 —— 高亮一个看不见的东西只会让
|
|
771
|
+
「N 处匹配」对不上眼睛看到的。 */
|
|
772
|
+
const attrVals = attrOpen
|
|
773
|
+
? Object.values(node.attributes).map((v) => (typeof v === 'string' ? v : String(v)))
|
|
774
|
+
: [];
|
|
775
|
+
if (hits([node.name, node.arg, bad ? node.error?.message : '', ...attrVals])) {
|
|
776
|
+
row.className += ' tr-hit';
|
|
777
|
+
}
|
|
646
778
|
row.appendChild(el('span', 'tr-pre', branch));
|
|
647
779
|
row.appendChild(el('span', 'tr-dot', node.done ? (bad ? '✕' : '●') : '◌'));
|
|
648
780
|
if (ut) row.appendChild(el('span', 'tr-ico', CAP_ICO[ut]));
|
|
@@ -664,10 +796,46 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
664
796
|
if (bad) parts.push(node.error?.type || 'error');
|
|
665
797
|
meta = parts.join(' · ');
|
|
666
798
|
} else meta = '…';
|
|
799
|
+
/* 时间轴条(2026-11-13):按 run 的总时长归一化 —— 「8 秒被谁吃掉 / 谁和谁并行」
|
|
800
|
+
只有结构视角是看不见的。放在 meta **之前**并自己吃掉 `margin-left: auto`,
|
|
801
|
+
让「耗时读数 — 轨道」贴在一起(两者说的是同一件事)。根行不画:它恒占满全宽。 */
|
|
802
|
+
if (!isRoot && timeline && t0 !== null && total > 0 && typeof node.startedAt === 'number') {
|
|
803
|
+
const offset = node.startedAt - t0;
|
|
804
|
+
const left = Math.min(1, Math.max(0, offset / total));
|
|
805
|
+
const dur = node.done && node.ms > 0 ? node.ms : Math.max(total - offset, 0);
|
|
806
|
+
const width = Math.min(1 - left, dur / total);
|
|
807
|
+
const track = el('span', 'tr-tl');
|
|
808
|
+
const bar = el('span', 'tr-tl-bar');
|
|
809
|
+
// 几何只能走 style(宽度是**数据**,不是样式选择);两端都 clamp 过 ⇒ 不会溢出轨道
|
|
810
|
+
bar.style.cssText = `left:${(left * 100).toFixed(2)}%;width:${Math.max(width * 100, 0.6).toFixed(2)}%`;
|
|
811
|
+
track.appendChild(bar);
|
|
812
|
+
track.title = `起点 ${Math.round(offset)}ms · 时长 ${Math.round(dur)}ms`;
|
|
813
|
+
row.dataset.tl = '1';
|
|
814
|
+
row.appendChild(track);
|
|
815
|
+
}
|
|
667
816
|
const metaEl = el('span', 'tr-meta', meta);
|
|
668
817
|
if (bad && node.error?.message) metaEl.title = node.error.message;
|
|
669
818
|
row.appendChild(metaEl);
|
|
819
|
+
/* span 属性(2026-11-13):`model` / `system.version` / `tools.names` / `retry.attempt` /
|
|
820
|
+
`handoff.*` / `guardrail.*` 全在 attributes 里,此前树上一格都不显示 ——
|
|
821
|
+
「这次是不是提示词版本没换」「是不是重试了三次」只能靠猜。
|
|
822
|
+
块体复用 `full-view.js`(纯 DOM 构造,不再写第二套渲染)。 */
|
|
823
|
+
if (attrKeys.length > 0) {
|
|
824
|
+
row.dataset.expandable = '1';
|
|
825
|
+
row.appendChild(el('span', 'tr-caret', attrOpen ? '▾' : '▸'));
|
|
826
|
+
row.addEventListener('click', () => {
|
|
827
|
+
if (!hasSelection()) toggleExpand(attrKey);
|
|
828
|
+
});
|
|
829
|
+
}
|
|
670
830
|
rootEl.appendChild(row);
|
|
831
|
+
if (attrOpen) {
|
|
832
|
+
let fold = folds.get(attrKey);
|
|
833
|
+
if (!fold) {
|
|
834
|
+
fold = new Map();
|
|
835
|
+
folds.set(attrKey, fold);
|
|
836
|
+
}
|
|
837
|
+
rootEl.appendChild(createFullView(node.attributes, { fold }));
|
|
838
|
+
}
|
|
671
839
|
// 失败原文必须**看得见** —— 只塞进 title(hover 才显)等于没显示。首次运行最常见的失败
|
|
672
840
|
// (没配 API key)就靠这一行定位;单起一行,不去挤占 nowrap 的 span 行。
|
|
673
841
|
if (bad && node.error?.message) {
|
|
@@ -693,10 +861,19 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
693
861
|
usageEls.cost.textContent = '$' + cost.toFixed(4);
|
|
694
862
|
}
|
|
695
863
|
|
|
696
|
-
/**
|
|
697
|
-
|
|
864
|
+
/**
|
|
865
|
+
* 开树。rootName 为 run 根显示名(如 'run · 审查一份文档')。
|
|
866
|
+
*
|
|
867
|
+
* `rootId` = **框架给这个根 span 的真 `spanId`**(`fromTrace.js` 的 `playTrace` 会传)。
|
|
868
|
+
* 为什么必须传:根节点上的事件(`score` / `trace.truncated` / `approval.decided`)由
|
|
869
|
+
* `event(span.spanId, …)` 投递,而旧实现只把根登记在字面量 `'root'` 上 ⇒ `spanMap.get`
|
|
870
|
+
* 落空 ⇒ `event()` 静默 return。症状不是报错,是**评估结论与截断警告整类消失**,而子 span
|
|
871
|
+
* 的事件一切正常(排查时最容易被判成「框架没记」)。字面量 `'root'` 仍登记:官网
|
|
872
|
+
* playground 的模拟脚本用它当根 id,两个键指向同一节点,零成本。
|
|
873
|
+
*/
|
|
874
|
+
function reset(rootName, rootId, startedAt, attributes) {
|
|
698
875
|
traceRoot = {
|
|
699
|
-
id: 'root',
|
|
876
|
+
id: rootId || 'root',
|
|
700
877
|
kind: 'run',
|
|
701
878
|
name: rootName || 'run',
|
|
702
879
|
children: [],
|
|
@@ -704,10 +881,23 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
704
881
|
order: [],
|
|
705
882
|
done: false,
|
|
706
883
|
ms: 0,
|
|
884
|
+
/* 时间轴的基准(2026-11-13):根 span 的起点。缺省 null ⇒ 整棵树不画条
|
|
885
|
+
(手写 trace / 官网模拟回放没有时间戳,画一条人人占满的条比不画更误导)。 */
|
|
886
|
+
startedAt: typeof startedAt === 'number' ? startedAt : null,
|
|
887
|
+
endAt: null,
|
|
888
|
+
/* 根 span 的属性(2026-11-13):`model` / `system.version` / `tools.names` /
|
|
889
|
+
`prompts.versions` / `stop_reason` **只写在根上**。根行不经过 `start()`
|
|
890
|
+
(它由 reset 建),所以不在这里接一手,这些键在面板上就一格都看不到 ——
|
|
891
|
+
「这次是不是提示词版本没换」只能去翻 JSON。空对象当没有。 */
|
|
892
|
+
attributes:
|
|
893
|
+
attributes && typeof attributes === 'object' && Object.keys(attributes).length > 0
|
|
894
|
+
? attributes
|
|
895
|
+
: null,
|
|
707
896
|
usage: { input: 0, output: 0 },
|
|
708
897
|
};
|
|
709
898
|
spanMap.clear();
|
|
710
899
|
spanMap.set('root', traceRoot);
|
|
900
|
+
if (rootId && rootId !== 'root') spanMap.set(rootId, traceRoot);
|
|
711
901
|
expanded.clear(); // 展开态属于上一棵树:reset 开新树时一并清掉
|
|
712
902
|
folds.clear(); // 块内折叠同理:留着只会在下一次点开时出现"莫名已折叠"的状态
|
|
713
903
|
usageAcc.input = 0;
|
|
@@ -728,6 +918,9 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
728
918
|
order: [],
|
|
729
919
|
done: false,
|
|
730
920
|
ms: 0,
|
|
921
|
+
startedAt: typeof s.startedAt === 'number' ? s.startedAt : null,
|
|
922
|
+
endAt: null,
|
|
923
|
+
attributes: s.attributes && typeof s.attributes === 'object' ? s.attributes : null,
|
|
731
924
|
status: 'ok',
|
|
732
925
|
error: null,
|
|
733
926
|
usage: null,
|
|
@@ -767,6 +960,14 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
767
960
|
if (!node) return;
|
|
768
961
|
node.done = true;
|
|
769
962
|
node.ms = s.ms || 0;
|
|
963
|
+
/* 终点优先取框架的真 `endedAt`(时间轴的右端);没有就按起点 + 时长推。
|
|
964
|
+
两者都没有(手写 trace)⇒ 留 null,那一段不画条。 */
|
|
965
|
+
node.endAt =
|
|
966
|
+
typeof s.endedAt === 'number'
|
|
967
|
+
? s.endedAt
|
|
968
|
+
: typeof node.startedAt === 'number'
|
|
969
|
+
? node.startedAt + node.ms
|
|
970
|
+
: null;
|
|
770
971
|
node.status = s.status || 'ok';
|
|
771
972
|
node.error = s.error || null;
|
|
772
973
|
if (s.arg) node.arg = s.arg;
|
|
@@ -793,6 +994,9 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
793
994
|
if (!traceRoot) return;
|
|
794
995
|
traceRoot.done = true;
|
|
795
996
|
traceRoot.ms = ms;
|
|
997
|
+
if (typeof traceRoot.startedAt === 'number' && typeof ms === 'number') {
|
|
998
|
+
traceRoot.endAt = traceRoot.startedAt + ms;
|
|
999
|
+
}
|
|
796
1000
|
// run 失败时根 span 也要标红:原先 status 恒为 ok,错误只体现在子 span 上
|
|
797
1001
|
traceRoot.status = status || 'ok';
|
|
798
1002
|
traceRoot.error = error || null;
|
|
@@ -800,14 +1004,76 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
800
1004
|
renderTrace();
|
|
801
1005
|
}
|
|
802
1006
|
|
|
1007
|
+
/** 当前命中行(`.tr-hit`)。宿主没有 `querySelectorAll`(stub / 极简 DOM)时返回空集 */
|
|
1008
|
+
function hitRows() {
|
|
1009
|
+
try {
|
|
1010
|
+
if (typeof rootEl.querySelectorAll !== 'function') return [];
|
|
1011
|
+
return Array.from(rootEl.querySelectorAll('.tr-hit'));
|
|
1012
|
+
} catch {
|
|
1013
|
+
return [];
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** 加/去一个类名(不依赖 classList:stub DOM 未必实现它) */
|
|
1018
|
+
function setFlag(node, cls, on) {
|
|
1019
|
+
const parts = String(node.className || '')
|
|
1020
|
+
.split(/\s+/)
|
|
1021
|
+
.filter((c) => c && c !== cls);
|
|
1022
|
+
if (on) parts.push(cls);
|
|
1023
|
+
node.className = parts.join(' ');
|
|
1024
|
+
}
|
|
1025
|
+
|
|
803
1026
|
return {
|
|
804
1027
|
reset,
|
|
805
1028
|
start,
|
|
806
1029
|
event,
|
|
807
1030
|
end,
|
|
808
1031
|
finish,
|
|
1032
|
+
/** 把一批动作压成一次重画(`playTrace` 必用;宿主自己批量喂时也可以用) */
|
|
1033
|
+
batch,
|
|
809
1034
|
render: renderTrace,
|
|
1035
|
+
/**
|
|
1036
|
+
* 树内检索:**只高亮 + 跳转,不改结构**(理由见 `query` 的头注)。返回命中行数。
|
|
1037
|
+
* 空串 = 结束检索(清掉所有高亮)。
|
|
1038
|
+
*/
|
|
1039
|
+
setQuery(q) {
|
|
1040
|
+
const next = typeof q === 'string' ? q.trim() : '';
|
|
1041
|
+
if (next === query) return hitRows().length;
|
|
1042
|
+
query = next;
|
|
1043
|
+
renderTrace();
|
|
1044
|
+
return hitRows().length;
|
|
1045
|
+
},
|
|
1046
|
+
get hitCount() {
|
|
1047
|
+
return hitRows().length;
|
|
1048
|
+
},
|
|
1049
|
+
/**
|
|
1050
|
+
* 跳到下一处命中(`dir < 0` 向上),返回 **1 基序号**;没有命中返回 0。
|
|
1051
|
+
* 越界绕回:搜索框里按 Enter 一路按到底再回到第一处,比「按不动了」更符合预期。
|
|
1052
|
+
*/
|
|
1053
|
+
nextHit(dir = 1) {
|
|
1054
|
+
const list = hitRows();
|
|
1055
|
+
if (list.length === 0) return 0;
|
|
1056
|
+
hitIndex = (hitIndex + (dir > 0 ? 1 : -1) + list.length) % list.length;
|
|
1057
|
+
const cur = list[hitIndex];
|
|
1058
|
+
for (const r of list) setFlag(r, 'tr-hit-cur', r === cur);
|
|
1059
|
+
try {
|
|
1060
|
+
cur.scrollIntoView?.({ block: 'center' });
|
|
1061
|
+
} catch {
|
|
1062
|
+
/* 没有滚动能力(stub / 老浏览器):序号仍然对,只是没把行带进视野 */
|
|
1063
|
+
}
|
|
1064
|
+
return hitIndex + 1;
|
|
1065
|
+
},
|
|
810
1066
|
setUsage: renderUsage,
|
|
1067
|
+
/** 时间轴开关(默认开)。关掉只是不画轨道,数据与命中都不受影响 */
|
|
1068
|
+
setTimeline(on) {
|
|
1069
|
+
const next = on !== false;
|
|
1070
|
+
if (next === timeline) return;
|
|
1071
|
+
timeline = next;
|
|
1072
|
+
renderTrace();
|
|
1073
|
+
},
|
|
1074
|
+
get timeline() {
|
|
1075
|
+
return timeline;
|
|
1076
|
+
},
|
|
811
1077
|
/** 换单价(如真实模式切服务商):null 表示无公开单价 → 成本显示为 — */
|
|
812
1078
|
setPrice(p) {
|
|
813
1079
|
price = p ?? null;
|
|
@@ -969,45 +1235,72 @@ function playTrace(view, trace) {
|
|
|
969
1235
|
}
|
|
970
1236
|
items.sort((a, b) => a.t - b.t || a.ord - b.ord || a.d - b.d || a.seq - b.seq);
|
|
971
1237
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1238
|
+
/** 把归一好的动作逐条喂给视图(不含 reset / finish —— 那两个由外层一起包进批里) */
|
|
1239
|
+
const replay = () => {
|
|
1240
|
+
for (const it of items) {
|
|
1241
|
+
if (it.kind === 'start') {
|
|
1242
|
+
if (it.s === root) continue; // 根由 reset 建,不重复开
|
|
1243
|
+
view.start({
|
|
1244
|
+
id: it.s.spanId,
|
|
1245
|
+
parent: it.s.parentSpanId,
|
|
1246
|
+
kind: it.s.kind,
|
|
1247
|
+
name: displayName(it.s),
|
|
1248
|
+
arg: '',
|
|
1249
|
+
startedAt: it.s.startedAt,
|
|
1250
|
+
// 属性(model / system.version / tools.names / retry.attempt / handoff.* / …):
|
|
1251
|
+
// 树上一格都不显示的话,「这次是不是提示词版本没换」只能靠猜(2026-11-13)
|
|
1252
|
+
attributes: it.s.attributes,
|
|
1253
|
+
});
|
|
1254
|
+
} else if (it.kind === 'event') {
|
|
1255
|
+
view.event(
|
|
1256
|
+
it.s.spanId,
|
|
1257
|
+
it.ev.name,
|
|
1258
|
+
eventToolName(it.ev.name, it.ev.body?.tool, typeMap),
|
|
1259
|
+
eventText(it.ev),
|
|
1260
|
+
it.ev.body?.ok !== false,
|
|
1261
|
+
eventFull(it.ev),
|
|
1262
|
+
);
|
|
1263
|
+
} else {
|
|
1264
|
+
view.end({
|
|
1265
|
+
id: it.s.spanId,
|
|
1266
|
+
ms: it.s.endedAt - it.s.startedAt,
|
|
1267
|
+
endedAt: it.s.endedAt,
|
|
1268
|
+
usage: usageOf(it.s),
|
|
1269
|
+
status: it.s.status,
|
|
1270
|
+
error: it.s.error,
|
|
1271
|
+
arg: '',
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1004
1274
|
}
|
|
1005
|
-
}
|
|
1275
|
+
};
|
|
1006
1276
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1277
|
+
const rootRaw = String(root.name || 'run');
|
|
1278
|
+
/* 整个回放(含 reset 与收尾)包在**一次**重画里(2026-11-13):此前每个动作都触发整树
|
|
1279
|
+
重建 ⇒ O(N²)(实测 600 span = 1801 次全树重建、假 DOM 就 172ms)。语义不变 ——
|
|
1280
|
+
渲染仍是全量重建(那是折叠态必须外部持有的原因),只是把 N 次压成 1 次。
|
|
1281
|
+
⚠️ `reset` 也必须在批内:它在批外会先白画一遍空树,收尾再画一遍 ⇒ 2 次。
|
|
1282
|
+
没有 `batch` 的宿主(自定义 view 对象 / 老版本)退回逐条画,行为不变。 */
|
|
1283
|
+
const replayWithFinish = () => {
|
|
1284
|
+
// 框架的 run 根名可能是 runName(如 'app')或已是 'run · x' 形态,避免重复前缀。
|
|
1285
|
+
// 第二个参数是**真 spanId**:根上的事件(score / trace.truncated / approval.decided)
|
|
1286
|
+
// 按 spanId 投递,只登记字面量 'root' 会让它们全部静默丢失(见 view.js 的 reset)。
|
|
1287
|
+
// 第三个是**根起点**:时间轴按它归一化(缺了就整棵树不画条)。
|
|
1288
|
+
// 第四个是**根的 attributes**:`model` / `system.version` / `tools.names` 只写在根上,
|
|
1289
|
+
// 而根行由 reset 建、不走 `view.start()` —— 不在这里递进去,这些键树上一格都没有。
|
|
1290
|
+
view.reset(
|
|
1291
|
+
rootRaw.startsWith('run') ? rootRaw : 'run · ' + rootRaw,
|
|
1292
|
+
String(root.spanId || ''),
|
|
1293
|
+
typeof root.startedAt === 'number' ? root.startedAt : undefined,
|
|
1294
|
+
root.attributes,
|
|
1295
|
+
);
|
|
1296
|
+
replay();
|
|
1297
|
+
/* 根 span 还没结束(`span.end` 尚未到达)⇒ **不收尾**:`finish()` 会把根标成已完成
|
|
1298
|
+
(● + 耗时),而这棵树其实还在长 —— 在飞时看到「已完成」是假事实。留 ◌ 才对。
|
|
1299
|
+
收尾的整棵 trace 根必有 `endedAt`,所以这条分支只对「在飞 / 截断」的树生效。 */
|
|
1300
|
+
if (root.endedAt != null) view.finish(root.endedAt - root.startedAt, root.status, root.error);
|
|
1301
|
+
};
|
|
1302
|
+
if (typeof view.batch === 'function') view.batch(replayWithFinish);
|
|
1303
|
+
else replayWithFinish();
|
|
1011
1304
|
return true;
|
|
1012
1305
|
}
|
|
1013
1306
|
|