@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
|
@@ -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 宽的右栏里折成十几行连续文本(页面不报错、无溢出,只是人读不了)。
|
|
@@ -296,6 +351,17 @@
|
|
|
296
351
|
padding: 0 4px;
|
|
297
352
|
margin-right: 4px;
|
|
298
353
|
}
|
|
354
|
+
/* `md`:这段正文被 Markdown 渲染过(2026-11-12)。与 `str→json` 同一套外观 ——
|
|
355
|
+
两者都是「你看到的不是原样」的告示,样式分叉只会让人以为它们是不同性质的东西。 */
|
|
356
|
+
.tr-full-md {
|
|
357
|
+
color: var(--faint);
|
|
358
|
+
font-size: 10px;
|
|
359
|
+
line-height: 1.4;
|
|
360
|
+
border: 1px solid #2c2c32;
|
|
361
|
+
border-radius: 3px;
|
|
362
|
+
padding: 0 4px;
|
|
363
|
+
margin-right: 4px;
|
|
364
|
+
}
|
|
299
365
|
/* `⧉` 复制路径:hover 才露。与 .tr-caret 的区别是**它暴露的是动作、不是信息** ——
|
|
300
366
|
路径的内容(键名)始终写在行里,常显只会给每行多一个字形噪声。
|
|
301
367
|
没有剪贴板实现时照样显示、点了不谎报成功(见 full-view.js 的 copyInto)。 */
|
package/dist/inspector/view.js
CHANGED
|
@@ -17,11 +17,14 @@
|
|
|
17
17
|
* 宿主需提供的 CSS 变量:--ice --faint --text(见 trace-view.css)。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
/*
|
|
20
|
+
/* 展开态的正文有三种形状(2026-11-11,2026-11-12 扩了两档):
|
|
21
21
|
* - 纯文本字符串 → 老路:`.tr-io` 里放原文,靠 `.tr-open` 换行(纯文本正文、官网模拟回放);
|
|
22
22
|
* - 对象/数组 → 追加一个 `.tr-full` 结构化块(折叠 / 成表 / 路径复制),`.tr-io` 由 CSS 隐藏;
|
|
23
23
|
* - **装着 JSON 的字符串**(`content: "{\"rows\":[...]}"`,工具把结构序列化后塞回文本字段)
|
|
24
|
-
* → 同上一档,块头写明「string · N 字符 → object · M 项」并打 `str→json`
|
|
24
|
+
* → 同上一档,块头写明「string · N 字符 → object · M 项」并打 `str→json` 标记;
|
|
25
|
+
* - **一整篇 Markdown 的字符串**(工具出参就是一份 SKILL.md / 报告)→ 由宿主注入的
|
|
26
|
+
* `opts.textBody(text)` 渲染成正文块,块头打 `md` 标记。注入是刻意的:本包是独立
|
|
27
|
+
* 发布单元,解析器只有一份(在宿主里),两边各带一份必然漂移。
|
|
25
28
|
* 判据与块内完全同源(`hasStructuredBody`),「挂不挂块」与「块里画什么」因此不会跑偏。
|
|
26
29
|
* 前两档并存是刻意的:纯文本那条路有 test/style.test.js 的两个不变量守着(最小宽度、caret 常显),
|
|
27
30
|
* 全换成本件会让那两个守卫失去落点。 */
|
|
@@ -91,10 +94,31 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
91
94
|
let price = opts.price ?? null;
|
|
92
95
|
const onUsage = opts.onUsage ?? null;
|
|
93
96
|
const usageEls = opts.usage ?? null;
|
|
97
|
+
/* 字符串正文的宿主渲染器(可选):`(text) => Element | null`。返回 null = 「不是那种
|
|
98
|
+
正文,保持原文」。本包不自带 Markdown 解析器 —— 见文件头注。 */
|
|
99
|
+
const textBody = typeof opts.textBody === 'function' ? opts.textBody : null;
|
|
94
100
|
|
|
95
101
|
let traceRoot = null;
|
|
96
102
|
const spanMap = new Map();
|
|
97
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;
|
|
98
122
|
/* 展开态的行 key 集合 + 事件 key 发号器。两者都必须活在 renderTrace 之外:
|
|
99
123
|
渲染是【全量重建】(rootEl.innerHTML = ''),状态放进 DOM 或渲染过程里就活不过下一次事件。 */
|
|
100
124
|
const expanded = new Set();
|
|
@@ -118,8 +142,66 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
118
142
|
renderTrace();
|
|
119
143
|
}
|
|
120
144
|
|
|
145
|
+
/** 宿主渲染器包一层:**只在展开时调用**(折叠态每条事件都会重画,挂在那儿等于白解析
|
|
146
|
+
* 一遍所有历史正文);宿主抛错也不许让整棵树白屏 —— 回落原文即可。 */
|
|
147
|
+
function textBodyOf(v) {
|
|
148
|
+
if (!textBody || typeof v !== 'string') return null;
|
|
149
|
+
try {
|
|
150
|
+
return textBody(v) || null;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 把一批渲染动作压成一次重画(见 `batchDepth` 的头注)。没在批里就直接画。 */
|
|
121
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() {
|
|
122
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
|
+
|
|
123
205
|
const rows = [];
|
|
124
206
|
/* 每个节点下【事件】与【子 span】按发生顺序混排:tool.input 先于它触发的 capability span、
|
|
125
207
|
tool.output 后于它,这个先后本身就是语义,不能拍平成一类。 */
|
|
@@ -142,6 +224,11 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
142
224
|
const open = expanded.has(ev.key);
|
|
143
225
|
const row = el('div', 'tr-row tr-ev' + (ev.ok === false ? ' error' : ''));
|
|
144
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';
|
|
145
232
|
row.dataset.capability = capabilityTypeOf(ev.tool);
|
|
146
233
|
row.appendChild(el('span', 'tr-pre', branch));
|
|
147
234
|
row.appendChild(el('span', 'tr-evv', ev.type === 'tool.output' ? '◂' : '▸'));
|
|
@@ -150,11 +237,13 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
150
237
|
// 这一格整个不渲染,别把「无工具」显示成假工具名(.tr-name 是 flex:none,.tr-io 自然占满)
|
|
151
238
|
if (ev.tool) row.appendChild(el('span', 'tr-name', ev.tool));
|
|
152
239
|
// 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致。
|
|
153
|
-
// `full`
|
|
154
|
-
// 仍放**原文**(结构化块挂在行后、由 CSS
|
|
155
|
-
// 62 字符摘要」这条不变量原样成立。
|
|
240
|
+
// `full` 是对象/数组、装着 JSON 的字符串、或宿主认得的正文(Markdown)时
|
|
241
|
+
// (2026-11-11 / 2026-11-12):`.tr-io` 仍放**原文**(结构化块挂在行后、由 CSS 隐藏本格),
|
|
242
|
+
// 所以「展开拿到的是原文而不是 62 字符摘要」这条不变量原样成立。
|
|
156
243
|
const structured = hasStructuredBody(ev.full);
|
|
157
|
-
const
|
|
244
|
+
const mdBody = open && !structured ? textBodyOf(ev.full) : null;
|
|
245
|
+
const blocked = structured || mdBody !== null;
|
|
246
|
+
const fullText = blocked ? rawArg(ev.full) : ev.full || '';
|
|
158
247
|
const io = el('span', 'tr-io', (open ? fullText : ev.text) || '');
|
|
159
248
|
// 展开后正文已在行里,再挂一个占满整行的原生 tooltip 只会挡视线
|
|
160
249
|
io.title = open ? '' : ev.text || '';
|
|
@@ -162,7 +251,7 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
162
251
|
if (ev.full) {
|
|
163
252
|
row.dataset.expandable = '1'; // 可展开标记:CSS 靠它出 caret / cursor,测试靠它找行
|
|
164
253
|
row.appendChild(el('span', 'tr-caret', open ? '▾' : '▸'));
|
|
165
|
-
if (open) row.className += ' tr-open' + (
|
|
254
|
+
if (open) row.className += ' tr-open' + (blocked ? ' tr-structured' : '');
|
|
166
255
|
row.addEventListener('click', () => {
|
|
167
256
|
if (!hasSelection()) toggleExpand(ev.key);
|
|
168
257
|
});
|
|
@@ -171,13 +260,15 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
171
260
|
// 结构化正文是**兄弟块**而不是 `.tr-io` 的子节点:行是 flex + nowrap,
|
|
172
261
|
// 塞进去会把树的列对齐打乱;独立成块才能自己换行、自己缩进。
|
|
173
262
|
// 折叠状态按事件键取一份带进去(2026-11-11 晚):块每次重建都 new,状态不能放块里。
|
|
174
|
-
|
|
263
|
+
// `body` 是宿主渲染好的正文(Markdown),交给同一条挂载路径:块头、复制、`.tr-io`
|
|
264
|
+
// 隐藏这些行为必须一致,否则「哪种正文」会从**外观**上分叉成两套。
|
|
265
|
+
if (open && blocked) {
|
|
175
266
|
let fold = folds.get(ev.key);
|
|
176
267
|
if (!fold) {
|
|
177
268
|
fold = new Map();
|
|
178
269
|
folds.set(ev.key, fold);
|
|
179
270
|
}
|
|
180
|
-
rootEl.appendChild(createFullView(ev.full, { fold }));
|
|
271
|
+
rootEl.appendChild(createFullView(ev.full, { fold, body: mdBody }));
|
|
181
272
|
}
|
|
182
273
|
return;
|
|
183
274
|
}
|
|
@@ -187,6 +278,18 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
187
278
|
row.dataset.kind = node.kind;
|
|
188
279
|
const ut = node.kind === 'capability' ? capabilityTypeOf(node.name) : '';
|
|
189
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
|
+
}
|
|
190
293
|
row.appendChild(el('span', 'tr-pre', branch));
|
|
191
294
|
row.appendChild(el('span', 'tr-dot', node.done ? (bad ? '✕' : '●') : '◌'));
|
|
192
295
|
if (ut) row.appendChild(el('span', 'tr-ico', CAP_ICO[ut]));
|
|
@@ -208,10 +311,46 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
208
311
|
if (bad) parts.push(node.error?.type || 'error');
|
|
209
312
|
meta = parts.join(' · ');
|
|
210
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
|
+
}
|
|
211
331
|
const metaEl = el('span', 'tr-meta', meta);
|
|
212
332
|
if (bad && node.error?.message) metaEl.title = node.error.message;
|
|
213
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
|
+
}
|
|
214
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
|
+
}
|
|
215
354
|
// 失败原文必须**看得见** —— 只塞进 title(hover 才显)等于没显示。首次运行最常见的失败
|
|
216
355
|
// (没配 API key)就靠这一行定位;单起一行,不去挤占 nowrap 的 span 行。
|
|
217
356
|
if (bad && node.error?.message) {
|
|
@@ -237,10 +376,19 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
237
376
|
usageEls.cost.textContent = '$' + cost.toFixed(4);
|
|
238
377
|
}
|
|
239
378
|
|
|
240
|
-
/**
|
|
241
|
-
|
|
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) {
|
|
242
390
|
traceRoot = {
|
|
243
|
-
id: 'root',
|
|
391
|
+
id: rootId || 'root',
|
|
244
392
|
kind: 'run',
|
|
245
393
|
name: rootName || 'run',
|
|
246
394
|
children: [],
|
|
@@ -248,10 +396,23 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
248
396
|
order: [],
|
|
249
397
|
done: false,
|
|
250
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,
|
|
251
411
|
usage: { input: 0, output: 0 },
|
|
252
412
|
};
|
|
253
413
|
spanMap.clear();
|
|
254
414
|
spanMap.set('root', traceRoot);
|
|
415
|
+
if (rootId && rootId !== 'root') spanMap.set(rootId, traceRoot);
|
|
255
416
|
expanded.clear(); // 展开态属于上一棵树:reset 开新树时一并清掉
|
|
256
417
|
folds.clear(); // 块内折叠同理:留着只会在下一次点开时出现"莫名已折叠"的状态
|
|
257
418
|
usageAcc.input = 0;
|
|
@@ -272,6 +433,9 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
272
433
|
order: [],
|
|
273
434
|
done: false,
|
|
274
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,
|
|
275
439
|
status: 'ok',
|
|
276
440
|
error: null,
|
|
277
441
|
usage: null,
|
|
@@ -311,6 +475,14 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
311
475
|
if (!node) return;
|
|
312
476
|
node.done = true;
|
|
313
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;
|
|
314
486
|
node.status = s.status || 'ok';
|
|
315
487
|
node.error = s.error || null;
|
|
316
488
|
if (s.arg) node.arg = s.arg;
|
|
@@ -337,6 +509,9 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
337
509
|
if (!traceRoot) return;
|
|
338
510
|
traceRoot.done = true;
|
|
339
511
|
traceRoot.ms = ms;
|
|
512
|
+
if (typeof traceRoot.startedAt === 'number' && typeof ms === 'number') {
|
|
513
|
+
traceRoot.endAt = traceRoot.startedAt + ms;
|
|
514
|
+
}
|
|
340
515
|
// run 失败时根 span 也要标红:原先 status 恒为 ok,错误只体现在子 span 上
|
|
341
516
|
traceRoot.status = status || 'ok';
|
|
342
517
|
traceRoot.error = error || null;
|
|
@@ -344,14 +519,76 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
344
519
|
renderTrace();
|
|
345
520
|
}
|
|
346
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
|
+
|
|
347
541
|
return {
|
|
348
542
|
reset,
|
|
349
543
|
start,
|
|
350
544
|
event,
|
|
351
545
|
end,
|
|
352
546
|
finish,
|
|
547
|
+
/** 把一批动作压成一次重画(`playTrace` 必用;宿主自己批量喂时也可以用) */
|
|
548
|
+
batch,
|
|
353
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
|
+
},
|
|
354
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
|
+
},
|
|
355
592
|
/** 换单价(如真实模式切服务商):null 表示无公开单价 → 成本显示为 — */
|
|
356
593
|
setPrice(p) {
|
|
357
594
|
price = p ?? null;
|