@migor/cli 0.10.9 → 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.
@@ -218,6 +218,61 @@
218
218
  min-width: 0;
219
219
  }
220
220
 
221
+ /* —— 时间轴(2026-11-13):树行右侧一条按 run 总时长归一化的细轨 ——
222
+ 结构视角看不出「谁和谁并行 / 8 秒被谁吃掉 / 首个 token 等了多久」,而每个 span 的
223
+ 起止本来就在数据里。最小形态 = 同一套行右侧加一条轨道,不引入第二套布局(瀑布图那种
224
+ 双栏会把已经够挤的树再压窄一半)。
225
+
226
+ `margin-left: auto` 让「读数 — 轨道」贴在一起:两者说的是同一件事(这个 span 花了多久)。
227
+ 条本身用 span 的绝对几何(`left` / `width` 百分比),两端都在 JS 里 clamp 过。 */
228
+ .tr-tl {
229
+ flex: none;
230
+ margin-left: auto;
231
+ width: 72px;
232
+ height: 5px;
233
+ position: relative;
234
+ background: #1b1b1f;
235
+ border-radius: 3px;
236
+ }
237
+ .tr-tl-bar {
238
+ position: absolute;
239
+ top: 0;
240
+ bottom: 0;
241
+ background: var(--ice);
242
+ opacity: 0.45;
243
+ border-radius: 3px;
244
+ }
245
+ .tr-row.error .tr-tl-bar {
246
+ background: #f7768e;
247
+ opacity: 0.6;
248
+ }
249
+ /* 有轨道的行:meta 不再吃 auto 边距(否则轨道被推到名字旁边、读数留在最右,两者脱节) */
250
+ .tr-row[data-tl] .tr-meta {
251
+ margin-left: 0;
252
+ padding-left: 8px;
253
+ }
254
+ /* span 行的 caret 与事件行同一个字形,但行内没有 `.tr-io` 可以让位 ⇒ 行自己留一条槽 */
255
+ .tr-row[data-expandable]:not(.tr-ev) {
256
+ padding-right: 14px;
257
+ }
258
+ /* 窄屏(面板退成单列时)把轨道收起来:72px 在那个宽度里是从名字里抢的 */
259
+ @media (max-width: 860px) {
260
+ .tr-tl {
261
+ display: none;
262
+ }
263
+ }
264
+
265
+ /* —— 树内检索(2026-11-13):**只标记,不改结构** ——
266
+ 裁剪节点会让「谁挂在谁下面」这个事实失真(隐藏父节点后子节点看起来属于别人),
267
+ 所以命中行只加底色;`-cur` 是「刚才跳到的那一处」,用来在连按 Enter 时不迷路。 */
268
+ .tr-hit {
269
+ background: rgba(224, 175, 104, 0.13);
270
+ }
271
+ .tr-hit-cur {
272
+ background: rgba(224, 175, 104, 0.28);
273
+ box-shadow: inset 2px 0 0 var(--warn, #e0af68);
274
+ }
275
+
221
276
  /* —— 结构化正文(2026-11-11):`full` 是对象/数组时,展开行**下方**多一个兄弟块 ——
222
277
  此前只有一条展开路:整串无缩进的 JSON 塞进 `.tr-io` 再靠 `.tr-open` 换行,20 个键就在
223
278
  400px 宽的右栏里折成十几行连续文本(页面不报错、无溢出,只是人读不了)。
@@ -101,6 +101,24 @@ export function createTraceView(rootEl, opts = {}) {
101
101
  let traceRoot = null;
102
102
  const spanMap = new Map();
103
103
  const usageAcc = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
104
+ /* 批量渲染(2026-11-13)。此前每来一条 span 动作就整树重建一次 ⇒ `playTrace` 是 O(N²):
105
+ 实测 50 span 触发 151 次全树重建、600 span 触发 1801 次(假 DOM 就 172ms),而在飞时
106
+ `renderLive` 每个 rAF 还会再调一次 `playTrace`。这不是「慢一点」,是长跑时面板直接卡死。
107
+ 语义不变:**渲染仍是全量重建**(那是折叠态必须外部持有的原因),只是把「一批动作之间
108
+ 的 N 次重建」压成「结束后一次」。
109
+ ⚠️ 嵌套是安全的(计数器),且批内抛错也会在 finally 里补画 —— 否则异常路径之后整棵树
110
+ 停在中间状态,而下一条事件又会被 `dirty` 吞掉。 */
111
+ let batchDepth = 0;
112
+ let batchDirty = false;
113
+ /* 树内检索(2026-11-13):**不改结构**,只给命中的行加类 + 提供「跳到下一处」。
114
+ 为什么不裁剪节点:树形是「谁挂在谁下面」的事实,隐藏父节点会让子节点看起来属于别人;
115
+ 而裁剪后「有多少条被藏起来了」又要另一处交代。高亮 + 跳转在不改事实的前提下解决
116
+ 「一百个 span 里找那一条」。 */
117
+ let query = '';
118
+ let hitIndex = -1;
119
+ /* 时间轴开关(2026-11-13):轨道占 72px,窄面板里是从名字里抢的 ⇒ 允许关掉。
120
+ 默认开 —— 它是「谁和谁并行 / 8 秒被谁吃掉」唯一的落点,而那条信息此前完全没有。 */
121
+ let timeline = true;
104
122
  /* 展开态的行 key 集合 + 事件 key 发号器。两者都必须活在 renderTrace 之外:
105
123
  渲染是【全量重建】(rootEl.innerHTML = ''),状态放进 DOM 或渲染过程里就活不过下一次事件。 */
106
124
  const expanded = new Set();
@@ -135,8 +153,55 @@ export function createTraceView(rootEl, opts = {}) {
135
153
  }
136
154
  }
137
155
 
156
+ /** 把一批渲染动作压成一次重画(见 `batchDepth` 的头注)。没在批里就直接画。 */
138
157
  function renderTrace() {
158
+ if (batchDepth > 0) {
159
+ batchDirty = true;
160
+ return;
161
+ }
162
+ drawTree();
163
+ }
164
+
165
+ function batch(fn) {
166
+ batchDepth += 1;
167
+ try {
168
+ fn();
169
+ } finally {
170
+ batchDepth -= 1;
171
+ // 批内一次都没标脏(例如只读了状态)⇒ 不白画一遍
172
+ if (batchDepth === 0 && batchDirty) {
173
+ batchDirty = false;
174
+ drawTree();
175
+ }
176
+ }
177
+ }
178
+
179
+ /** 检索:这一行的文本是否命中。空查询恒 false(「没在搜」不等于「全都命中」) */
180
+ function hits(texts) {
181
+ if (!query) return false;
182
+ const q = query.toLowerCase();
183
+ for (const t of texts) if (t && String(t).toLowerCase().includes(q)) return true;
184
+ return false;
185
+ }
186
+
187
+ function drawTree() {
139
188
  rootEl.innerHTML = '';
189
+ hitIndex = -1;
190
+ // 还没 reset 过(视图刚建出来):清空即返回。setTimeline / setQuery 可能先于第一棵树
191
+ // 被调用 —— 没有这一条,`traceRoot.order` 会抛,而调用方只是在设一个开关。
192
+ if (!traceRoot) return;
193
+ /* 时间轴的归一化基准:根 span 的起点与整棵树的终点。两者缺一就**不画条**
194
+ (手写 trace / 官网模拟回放没有时间戳 —— 画一条人人占满的条比不画更误导)。 */
195
+ const t0 = traceRoot && typeof traceRoot.startedAt === 'number' ? traceRoot.startedAt : null;
196
+ let tMax = t0;
197
+ if (t0 !== null) {
198
+ for (const n of spanMap.values()) {
199
+ const e = typeof n.endAt === 'number' ? n.endAt : n.startedAt;
200
+ if (typeof e === 'number' && e > tMax) tMax = e;
201
+ }
202
+ }
203
+ const total = t0 !== null && tMax !== null ? Math.max(0, tMax - t0) : 0;
204
+
140
205
  const rows = [];
141
206
  /* 每个节点下【事件】与【子 span】按发生顺序混排:tool.input 先于它触发的 capability span、
142
207
  tool.output 后于它,这个先后本身就是语义,不能拍平成一类。 */
@@ -159,6 +224,11 @@ export function createTraceView(rootEl, opts = {}) {
159
224
  const open = expanded.has(ev.key);
160
225
  const row = el('div', 'tr-row tr-ev' + (ev.ok === false ? ' error' : ''));
161
226
  row.dataset.ev = ev.type;
227
+ /* 检索只扫摘要与类型/工具名,**不扫完整正文**:`full` 常见 10KB 级,每帧每行
228
+ indexOf 一遍会让「搜索」本身成为掉帧的原因(600 行 × 10KB ≈ 6MB/帧)。
229
+ 2000 字符以内才扫 —— 那个尺度上全文搜索才有意义(再长就是文档,不是一行)。 */
230
+ const fullShort = typeof ev.full === 'string' && ev.full.length <= 2000 ? ev.full : '';
231
+ if (hits([ev.type, ev.tool, ev.text, fullShort])) row.className += ' tr-hit';
162
232
  row.dataset.capability = capabilityTypeOf(ev.tool);
163
233
  row.appendChild(el('span', 'tr-pre', branch));
164
234
  row.appendChild(el('span', 'tr-evv', ev.type === 'tool.output' ? '◂' : '▸'));
@@ -208,6 +278,18 @@ export function createTraceView(rootEl, opts = {}) {
208
278
  row.dataset.kind = node.kind;
209
279
  const ut = node.kind === 'capability' ? capabilityTypeOf(node.name) : '';
210
280
  if (ut) row.dataset.capability = ut;
281
+ const attrKeys = node.attributes ? Object.keys(node.attributes) : [];
282
+ const attrKey = 'sp:' + node.id;
283
+ const attrOpen = attrKeys.length > 0 && expanded.has(attrKey);
284
+ /* 命中面:名字 / 入参摘要 / 错误原文 / **已展开的**属性值。
285
+ 属性只在展开时才算:折叠态那些值本来就没显示 —— 高亮一个看不见的东西只会让
286
+ 「N 处匹配」对不上眼睛看到的。 */
287
+ const attrVals = attrOpen
288
+ ? Object.values(node.attributes).map((v) => (typeof v === 'string' ? v : String(v)))
289
+ : [];
290
+ if (hits([node.name, node.arg, bad ? node.error?.message : '', ...attrVals])) {
291
+ row.className += ' tr-hit';
292
+ }
211
293
  row.appendChild(el('span', 'tr-pre', branch));
212
294
  row.appendChild(el('span', 'tr-dot', node.done ? (bad ? '✕' : '●') : '◌'));
213
295
  if (ut) row.appendChild(el('span', 'tr-ico', CAP_ICO[ut]));
@@ -229,10 +311,46 @@ export function createTraceView(rootEl, opts = {}) {
229
311
  if (bad) parts.push(node.error?.type || 'error');
230
312
  meta = parts.join(' · ');
231
313
  } else meta = '…';
314
+ /* 时间轴条(2026-11-13):按 run 的总时长归一化 —— 「8 秒被谁吃掉 / 谁和谁并行」
315
+ 只有结构视角是看不见的。放在 meta **之前**并自己吃掉 `margin-left: auto`,
316
+ 让「耗时读数 — 轨道」贴在一起(两者说的是同一件事)。根行不画:它恒占满全宽。 */
317
+ if (!isRoot && timeline && t0 !== null && total > 0 && typeof node.startedAt === 'number') {
318
+ const offset = node.startedAt - t0;
319
+ const left = Math.min(1, Math.max(0, offset / total));
320
+ const dur = node.done && node.ms > 0 ? node.ms : Math.max(total - offset, 0);
321
+ const width = Math.min(1 - left, dur / total);
322
+ const track = el('span', 'tr-tl');
323
+ const bar = el('span', 'tr-tl-bar');
324
+ // 几何只能走 style(宽度是**数据**,不是样式选择);两端都 clamp 过 ⇒ 不会溢出轨道
325
+ bar.style.cssText = `left:${(left * 100).toFixed(2)}%;width:${Math.max(width * 100, 0.6).toFixed(2)}%`;
326
+ track.appendChild(bar);
327
+ track.title = `起点 ${Math.round(offset)}ms · 时长 ${Math.round(dur)}ms`;
328
+ row.dataset.tl = '1';
329
+ row.appendChild(track);
330
+ }
232
331
  const metaEl = el('span', 'tr-meta', meta);
233
332
  if (bad && node.error?.message) metaEl.title = node.error.message;
234
333
  row.appendChild(metaEl);
334
+ /* span 属性(2026-11-13):`model` / `system.version` / `tools.names` / `retry.attempt` /
335
+ `handoff.*` / `guardrail.*` 全在 attributes 里,此前树上一格都不显示 ——
336
+ 「这次是不是提示词版本没换」「是不是重试了三次」只能靠猜。
337
+ 块体复用 `full-view.js`(纯 DOM 构造,不再写第二套渲染)。 */
338
+ if (attrKeys.length > 0) {
339
+ row.dataset.expandable = '1';
340
+ row.appendChild(el('span', 'tr-caret', attrOpen ? '▾' : '▸'));
341
+ row.addEventListener('click', () => {
342
+ if (!hasSelection()) toggleExpand(attrKey);
343
+ });
344
+ }
235
345
  rootEl.appendChild(row);
346
+ if (attrOpen) {
347
+ let fold = folds.get(attrKey);
348
+ if (!fold) {
349
+ fold = new Map();
350
+ folds.set(attrKey, fold);
351
+ }
352
+ rootEl.appendChild(createFullView(node.attributes, { fold }));
353
+ }
236
354
  // 失败原文必须**看得见** —— 只塞进 title(hover 才显)等于没显示。首次运行最常见的失败
237
355
  // (没配 API key)就靠这一行定位;单起一行,不去挤占 nowrap 的 span 行。
238
356
  if (bad && node.error?.message) {
@@ -258,10 +376,19 @@ export function createTraceView(rootEl, opts = {}) {
258
376
  usageEls.cost.textContent = '$' + cost.toFixed(4);
259
377
  }
260
378
 
261
- /** 开树。rootName 为 run 根显示名(如 'run · 审查一份文档') */
262
- function reset(rootName) {
379
+ /**
380
+ * 开树。rootName 为 run 根显示名(如 'run · 审查一份文档')。
381
+ *
382
+ * `rootId` = **框架给这个根 span 的真 `spanId`**(`fromTrace.js` 的 `playTrace` 会传)。
383
+ * 为什么必须传:根节点上的事件(`score` / `trace.truncated` / `approval.decided`)由
384
+ * `event(span.spanId, …)` 投递,而旧实现只把根登记在字面量 `'root'` 上 ⇒ `spanMap.get`
385
+ * 落空 ⇒ `event()` 静默 return。症状不是报错,是**评估结论与截断警告整类消失**,而子 span
386
+ * 的事件一切正常(排查时最容易被判成「框架没记」)。字面量 `'root'` 仍登记:官网
387
+ * playground 的模拟脚本用它当根 id,两个键指向同一节点,零成本。
388
+ */
389
+ function reset(rootName, rootId, startedAt, attributes) {
263
390
  traceRoot = {
264
- id: 'root',
391
+ id: rootId || 'root',
265
392
  kind: 'run',
266
393
  name: rootName || 'run',
267
394
  children: [],
@@ -269,10 +396,23 @@ export function createTraceView(rootEl, opts = {}) {
269
396
  order: [],
270
397
  done: false,
271
398
  ms: 0,
399
+ /* 时间轴的基准(2026-11-13):根 span 的起点。缺省 null ⇒ 整棵树不画条
400
+ (手写 trace / 官网模拟回放没有时间戳,画一条人人占满的条比不画更误导)。 */
401
+ startedAt: typeof startedAt === 'number' ? startedAt : null,
402
+ endAt: null,
403
+ /* 根 span 的属性(2026-11-13):`model` / `system.version` / `tools.names` /
404
+ `prompts.versions` / `stop_reason` **只写在根上**。根行不经过 `start()`
405
+ (它由 reset 建),所以不在这里接一手,这些键在面板上就一格都看不到 ——
406
+ 「这次是不是提示词版本没换」只能去翻 JSON。空对象当没有。 */
407
+ attributes:
408
+ attributes && typeof attributes === 'object' && Object.keys(attributes).length > 0
409
+ ? attributes
410
+ : null,
272
411
  usage: { input: 0, output: 0 },
273
412
  };
274
413
  spanMap.clear();
275
414
  spanMap.set('root', traceRoot);
415
+ if (rootId && rootId !== 'root') spanMap.set(rootId, traceRoot);
276
416
  expanded.clear(); // 展开态属于上一棵树:reset 开新树时一并清掉
277
417
  folds.clear(); // 块内折叠同理:留着只会在下一次点开时出现"莫名已折叠"的状态
278
418
  usageAcc.input = 0;
@@ -293,6 +433,9 @@ export function createTraceView(rootEl, opts = {}) {
293
433
  order: [],
294
434
  done: false,
295
435
  ms: 0,
436
+ startedAt: typeof s.startedAt === 'number' ? s.startedAt : null,
437
+ endAt: null,
438
+ attributes: s.attributes && typeof s.attributes === 'object' ? s.attributes : null,
296
439
  status: 'ok',
297
440
  error: null,
298
441
  usage: null,
@@ -332,6 +475,14 @@ export function createTraceView(rootEl, opts = {}) {
332
475
  if (!node) return;
333
476
  node.done = true;
334
477
  node.ms = s.ms || 0;
478
+ /* 终点优先取框架的真 `endedAt`(时间轴的右端);没有就按起点 + 时长推。
479
+ 两者都没有(手写 trace)⇒ 留 null,那一段不画条。 */
480
+ node.endAt =
481
+ typeof s.endedAt === 'number'
482
+ ? s.endedAt
483
+ : typeof node.startedAt === 'number'
484
+ ? node.startedAt + node.ms
485
+ : null;
335
486
  node.status = s.status || 'ok';
336
487
  node.error = s.error || null;
337
488
  if (s.arg) node.arg = s.arg;
@@ -358,6 +509,9 @@ export function createTraceView(rootEl, opts = {}) {
358
509
  if (!traceRoot) return;
359
510
  traceRoot.done = true;
360
511
  traceRoot.ms = ms;
512
+ if (typeof traceRoot.startedAt === 'number' && typeof ms === 'number') {
513
+ traceRoot.endAt = traceRoot.startedAt + ms;
514
+ }
361
515
  // run 失败时根 span 也要标红:原先 status 恒为 ok,错误只体现在子 span 上
362
516
  traceRoot.status = status || 'ok';
363
517
  traceRoot.error = error || null;
@@ -365,14 +519,76 @@ export function createTraceView(rootEl, opts = {}) {
365
519
  renderTrace();
366
520
  }
367
521
 
522
+ /** 当前命中行(`.tr-hit`)。宿主没有 `querySelectorAll`(stub / 极简 DOM)时返回空集 */
523
+ function hitRows() {
524
+ try {
525
+ if (typeof rootEl.querySelectorAll !== 'function') return [];
526
+ return Array.from(rootEl.querySelectorAll('.tr-hit'));
527
+ } catch {
528
+ return [];
529
+ }
530
+ }
531
+
532
+ /** 加/去一个类名(不依赖 classList:stub DOM 未必实现它) */
533
+ function setFlag(node, cls, on) {
534
+ const parts = String(node.className || '')
535
+ .split(/\s+/)
536
+ .filter((c) => c && c !== cls);
537
+ if (on) parts.push(cls);
538
+ node.className = parts.join(' ');
539
+ }
540
+
368
541
  return {
369
542
  reset,
370
543
  start,
371
544
  event,
372
545
  end,
373
546
  finish,
547
+ /** 把一批动作压成一次重画(`playTrace` 必用;宿主自己批量喂时也可以用) */
548
+ batch,
374
549
  render: renderTrace,
550
+ /**
551
+ * 树内检索:**只高亮 + 跳转,不改结构**(理由见 `query` 的头注)。返回命中行数。
552
+ * 空串 = 结束检索(清掉所有高亮)。
553
+ */
554
+ setQuery(q) {
555
+ const next = typeof q === 'string' ? q.trim() : '';
556
+ if (next === query) return hitRows().length;
557
+ query = next;
558
+ renderTrace();
559
+ return hitRows().length;
560
+ },
561
+ get hitCount() {
562
+ return hitRows().length;
563
+ },
564
+ /**
565
+ * 跳到下一处命中(`dir < 0` 向上),返回 **1 基序号**;没有命中返回 0。
566
+ * 越界绕回:搜索框里按 Enter 一路按到底再回到第一处,比「按不动了」更符合预期。
567
+ */
568
+ nextHit(dir = 1) {
569
+ const list = hitRows();
570
+ if (list.length === 0) return 0;
571
+ hitIndex = (hitIndex + (dir > 0 ? 1 : -1) + list.length) % list.length;
572
+ const cur = list[hitIndex];
573
+ for (const r of list) setFlag(r, 'tr-hit-cur', r === cur);
574
+ try {
575
+ cur.scrollIntoView?.({ block: 'center' });
576
+ } catch {
577
+ /* 没有滚动能力(stub / 老浏览器):序号仍然对,只是没把行带进视野 */
578
+ }
579
+ return hitIndex + 1;
580
+ },
375
581
  setUsage: renderUsage,
582
+ /** 时间轴开关(默认开)。关掉只是不画轨道,数据与命中都不受影响 */
583
+ setTimeline(on) {
584
+ const next = on !== false;
585
+ if (next === timeline) return;
586
+ timeline = next;
587
+ renderTrace();
588
+ },
589
+ get timeline() {
590
+ return timeline;
591
+ },
376
592
  /** 换单价(如真实模式切服务商):null 表示无公开单价 → 成本显示为 — */
377
593
  setPrice(p) {
378
594
  price = p ?? null;