@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.
@@ -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>}} [opts]
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
- // 根是字符串 ⇒ 只有「里面装着 JSON」才会进来(调用方用 hasStructuredBody 先筛过)。
418
- // 头部同时写出**两件事**:它原本是字符串(字符数)、解析后是什么规模 —— 只写后者,
419
- // 读者会以为工具真的返回了对象。
420
- const parsedRoot = jsonInString(value);
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
- head.appendChild(
430
- fvEl('span', 'tr-full-type', fromJson ? `string · ${value.length} 字符 → ${label}` : label),
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);容器根复制的是缩进后的 JSON
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
- const ctx = { body, fold, repaint: null };
444
- // 只重画正文(不动头部):折叠不需要重建整棵树,滚动位置与头部反馈都留着
445
- ctx.repaint = () => {
446
- body.innerHTML = '';
447
- // 根若是从字符串解析来的,它的子树不再二次解析(只解析一层,防嵌套放大)
448
- renderInto(ctx, root, 0, '', !fromJson);
449
- };
450
- ctx.repaint();
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
- /* 展开态的正文有两种形状,由 `full` 的类型决定(2026-11-11,2026-11-12 扩了一档):
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` 是对象/数组、或**装着 JSON 的字符串**时(2026-11-11 / 2026-11-12):`.tr-io`
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 fullText = structured ? rawArg(ev.full) : ev.full || '';
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' + (structured ? ' tr-structured' : '');
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
- if (open && structured) {
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
- /** 开树。rootName 为 run 根显示名(如 'run · 审查一份文档') */
697
- function reset(rootName) {
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
- const rootRaw = String(root.name || 'run');
973
- // 框架的 run 根名可能是 runName(如 'app')或已是 'run · x' 形态,避免重复前缀
974
- view.reset(rootRaw.startsWith('run') ? rootRaw : 'run · ' + rootRaw);
975
-
976
- for (const it of items) {
977
- if (it.kind === 'start') {
978
- if (it.s === root) continue; // 根由 reset 建,不重复开
979
- view.start({
980
- id: it.s.spanId,
981
- parent: it.s.parentSpanId,
982
- kind: it.s.kind,
983
- name: displayName(it.s),
984
- arg: '',
985
- });
986
- } else if (it.kind === 'event') {
987
- view.event(
988
- it.s.spanId,
989
- it.ev.name,
990
- eventToolName(it.ev.name, it.ev.body?.tool, typeMap),
991
- eventText(it.ev),
992
- it.ev.body?.ok !== false,
993
- eventFull(it.ev),
994
- );
995
- } else {
996
- view.end({
997
- id: it.s.spanId,
998
- ms: it.s.endedAt - it.s.startedAt,
999
- usage: usageOf(it.s),
1000
- status: it.s.status,
1001
- error: it.s.error,
1002
- arg: '',
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
- // 根 span 还没结束(`span.end` 尚未到达)⇒ **不收尾**:`finish()` 会把根标成已完成
1008
- // (● + 耗时),而这棵树其实还在长 —— 在飞时看到「已完成」是假事实。留 ◌ 才对。
1009
- // 收尾的整棵 trace 根必有 `endedAt`,所以这条分支只对「在飞 / 截断」的树生效。
1010
- if (root.endedAt != null) view.finish(root.endedAt - root.startedAt, root.status, root.error);
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