@migor/cli 0.10.6 → 0.10.8
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 +86 -1
- package/dist/inspector/fromTrace.js +16 -7
- package/dist/inspector/full-view.js +449 -0
- package/dist/inspector/index.js +6 -0
- package/dist/inspector/trace-view.bundle.js +510 -9
- package/dist/inspector/trace-view.css +156 -0
- package/dist/inspector/view.js +33 -3
- package/dist/inspector-routes.js +4 -0
- package/dist/templates/package.json +2 -2
- package/package.json +1 -1
|
@@ -2,6 +2,457 @@
|
|
|
2
2
|
* 用途:`agentia view` 出的单文件自包含 HTML 内联本文件(无外链 / 离线可开 / 可转发)。
|
|
3
3
|
* 真源仍是 src/;改渲染逻辑请改那里再跑 build。零依赖、纯 ESM。 */
|
|
4
4
|
|
|
5
|
+
/* ===== src/full-view.js ===== */
|
|
6
|
+
/* 展开态的**结构化正文** —— 把「一大坨 JSON」拆成可折叠、可成表、可复制路径的块。
|
|
7
|
+
*
|
|
8
|
+
* ## 为什么需要它
|
|
9
|
+
*
|
|
10
|
+
* 展开此前只有一条路:把 `rawArg(v)`(= `JSON.stringify(v)`,**无缩进**)整串塞进
|
|
11
|
+
* `.tr-io` 再靠 CSS 换行。一个 20 键的工具入参在 400px 宽的右栏里会折成十几行连续的
|
|
12
|
+
* `{"a":"b","c":{"d":` —— 页面不报错、无溢出、构建全绿,只是**人读不了**。工具出参更糟:
|
|
13
|
+
* 它是 `content` 的原始序列化,长文本与图片块混在一起,一行到底。
|
|
14
|
+
*
|
|
15
|
+
* 缩进只是第一档。第二档(2026-11-11 晚,本文件的现状)解决缩进解决不了的三件事:
|
|
16
|
+
*
|
|
17
|
+
* 1. **静态文本没有"收起"**:深度 6 全铺开,一个出参就能把整棵树顶到屏幕外。现在容器
|
|
18
|
+
* 默认只铺两层(`OPEN_DEPTH`),其余折成 `{ object · 4 项 }`,点一下展开。折叠状态由
|
|
19
|
+
* 调用方传入(`opts.fold`)—— 渲染是全量重建,状态放本文件里活不过下一条事件。
|
|
20
|
+
* 2. **同构对象数组在浪费行**:`[{name,score},{...},{...}]` 每个元素重复一遍键名,3 行数据
|
|
21
|
+
* 吃掉 9 行。同构且各列都是标量时改成表格,表头一行、数据一行。
|
|
22
|
+
* 3. **路径拿不到**:想引用 `results[0].name` 只能用手数层级。每个键悬停出一个 `⧉`,
|
|
23
|
+
* 点一下把路径写进剪贴板(表头写的是 `results[*].name`,因为它是一整列)。
|
|
24
|
+
* 4. **字符串里套着的 JSON 读不了**(2026-11-12):MCP 的 text block、SSE 的 `data:`、把结果
|
|
25
|
+
* 序列化后又塞回 `content` 的工具 —— 这些值的类型是**字符串**,此前只显示成一串
|
|
26
|
+
* `{\"a\":1` 的反斜杠汤,比无缩进的 JSON 更难读。现在首尾是 `{`…`}` / `[`…`]` 且解析成功的
|
|
27
|
+
* 字符串按容器铺开,并在行里打 `str→json` 标记 —— 不标出来,读者会以为工具真的返回了对象。
|
|
28
|
+
*
|
|
29
|
+
* ## 边界(都是「不这么做就会坏」的地方,不是口味)
|
|
30
|
+
*
|
|
31
|
+
* - **只在 `full` 不是字符串时接手**:字符串正文(工具返回的纯文本、官网模拟回放)
|
|
32
|
+
* 继续走 `.tr-io` + `.tr-open` 的老路 —— 那条路有 `test/style.test.js` 的两个不变量守着
|
|
33
|
+
* (最小宽度、caret 常显),换成本件就会让那两个守卫失去落点。两档并存是刻意的。
|
|
34
|
+
* - **不改 `.tr-io` 的文本**:本件只是**追加**一个兄弟块,`.tr-io` 仍持有原文(展开时由
|
|
35
|
+
* CSS 隐藏)。既有断言(`textContent === JSON.stringify(...)`)因此逐字保留 ——
|
|
36
|
+
* 展开的正文依旧是"原文",只是多了一层可读的呈现。
|
|
37
|
+
* - **截断必须说出来**:长字符串与超大集合会截断,但截断处标明「还有 N 字符 / N 项」。
|
|
38
|
+
* 静默截断会让人以为工具就返回了这么点东西 —— 比糊成一坨更坏。表格单元格同样走
|
|
39
|
+
* `primitive()`,所以这条在表里也成立(不做「表格例外」)。
|
|
40
|
+
* - **循环引用不抛**:手写的 trace 可能有环(`rawArg` 对它会回落 `[object Object]`),
|
|
41
|
+
* 这里同样只回落、不抛错:调试面板不该因为一条坏数据整棵树白屏。
|
|
42
|
+
* - **折叠状态必须外部持有**:`opts.fold` 是调用方的 Map(`view.js` 按事件键存一份)。
|
|
43
|
+
* 本文件只读写它、不 new 一份长期状态 —— 否则「点开一个容器,下一条事件到达时它自己合上」
|
|
44
|
+
* 会原样复现(这正是 `view.js` 的 `expanded` 集合存在的原因)。
|
|
45
|
+
* - **表格化只在"真的同构"时发生**:元素必须全是普通对象、键集完全一致、列数 1..6、
|
|
46
|
+
* 每列都是标量。任一不满足就回落树形 —— 表格里没法逐层展开,判错一次就是**丢数据**。
|
|
47
|
+
* - **字符串里的 JSON 只解析一层,且必须标出来**:判据是**首尾字符**(`{`…`}` / `[`…`]`),
|
|
48
|
+
* 不是「`JSON.parse` 成功」—— `"123"` / `"null"` / `"true"` 都能 parse 成功,显示成
|
|
49
|
+
* 数字/空值/布尔就是**改写工具返回的事实**。解析结果内部的字符串**不再二次解析**:
|
|
50
|
+
* 一层套一层是业务里的真实现象(SSE data 里再套 JSON),逐层展开会让 10KB 变成十万个节点。
|
|
51
|
+
* 表格单元格也不解析 —— 同构表已经放弃了逐层展开,单元格里放不下嵌套块。
|
|
52
|
+
*
|
|
53
|
+
* 零依赖、纯 DOM 构造(只用 createElement / appendChild / textContent / className /
|
|
54
|
+
* dataset / innerHTML=''),与 view.js 共用同一套 DOM stub 跑测试。
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** 单个字符串值最多显示多少字符(超过就说出来,不静默截断) */
|
|
58
|
+
const MAX_STR = 240;
|
|
59
|
+
/** 一层里最多铺开多少项(再多就没法看了,留着类型与计数) */
|
|
60
|
+
const MAX_ITEMS = 40;
|
|
61
|
+
/** 嵌套深度上限:再深就整体序列化成一行,避免缩进把宽度吃光 */
|
|
62
|
+
const MAX_DEPTH = 6;
|
|
63
|
+
/** 默认展开到第几层(容器自身深度 < 它 ⇒ 默认铺开)。两层 = 根 + 一层子节点 */
|
|
64
|
+
const OPEN_DEPTH = 2;
|
|
65
|
+
/** 表格最多几列:再多横向就没法看了,回落树形 */
|
|
66
|
+
const MAX_COLS = 6;
|
|
67
|
+
/** 多长的字符串才值得尝试从里面解析 JSON。再长的话,parse 与随后的渲染成本都不值得 */
|
|
68
|
+
const MAX_JSON_STR = 100_000;
|
|
69
|
+
|
|
70
|
+
function fvEl(tag, cls, text) {
|
|
71
|
+
const node = document.createElement(tag);
|
|
72
|
+
if (cls) node.className = cls;
|
|
73
|
+
if (text !== undefined) node.textContent = text;
|
|
74
|
+
return node;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 安全序列化:循环引用 / BigInt / 自定义 toJSON 抛错都回落成一句人话,绝不冒泡 */
|
|
78
|
+
function safeStringify(v) {
|
|
79
|
+
try {
|
|
80
|
+
const s = JSON.stringify(v, null, 2);
|
|
81
|
+
return s === undefined ? String(v) : s;
|
|
82
|
+
} catch {
|
|
83
|
+
return '[无法序列化 —— 可能是循环引用]';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 该值是否值得走结构化视图(字符串交给 .tr-io 的老路) */
|
|
88
|
+
function isStructured(v) {
|
|
89
|
+
return v !== null && typeof v === 'object';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 有没有「结构化正文」可铺 —— 容器,或者**装着 JSON 的字符串**。
|
|
94
|
+
*
|
|
95
|
+
* 后者是业务里的常态:`tool.output` 的 `content` 常常就是 `JSON.stringify(...)` 的结果
|
|
96
|
+
* (工具把结构序列化后塞回文本字段,MCP 的 text block 更是如此)。此前这种值是字符串 ⇒
|
|
97
|
+
* 走纯文本老路 ⇒ 展开后是一串 `{\"a\":1` 的反斜杠汤。判据与 `jsonInString` 完全同一套,
|
|
98
|
+
* 所以「挂不挂块」与「块里画什么」不会跑偏。
|
|
99
|
+
*/
|
|
100
|
+
function hasStructuredBody(v) {
|
|
101
|
+
return isStructured(v) || jsonInString(v) !== null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 普通对象(排除数组与 null)—— 表格化与"容器"判定都只要这一种 */
|
|
105
|
+
function isPlainObject(v) {
|
|
106
|
+
return isStructured(v) && !Array.isArray(v);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 容器规模:数组算元素数,对象算键数 */
|
|
110
|
+
function sizeOf(v) {
|
|
111
|
+
return Array.isArray(v) ? v.length : Object.keys(v).length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 头部那句类型说明:`object · 6 项` / `array · 12 项` / `string · 4,096 字符` */
|
|
115
|
+
function typeLabelOf(v) {
|
|
116
|
+
if (Array.isArray(v)) return `array · ${v.length} 项`;
|
|
117
|
+
if (isStructured(v)) return `object · ${Object.keys(v).length} 项`;
|
|
118
|
+
if (typeof v === 'string') return `string · ${v.length} 字符`;
|
|
119
|
+
return typeof v;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 基本类型的着色类与展示文本 */
|
|
123
|
+
function primitive(v) {
|
|
124
|
+
if (v === null) return { cls: 'tr-full-null', text: 'null' };
|
|
125
|
+
if (v === undefined) return { cls: 'tr-full-null', text: 'undefined' };
|
|
126
|
+
const t = typeof v;
|
|
127
|
+
if (t === 'string') {
|
|
128
|
+
const clipped = v.length > MAX_STR ? v.slice(0, MAX_STR) : v;
|
|
129
|
+
const rest = v.length > MAX_STR ? `… 还有 ${v.length - MAX_STR} 字符` : '';
|
|
130
|
+
return { cls: 'tr-full-str', text: JSON.stringify(clipped) + rest };
|
|
131
|
+
}
|
|
132
|
+
if (t === 'number') return { cls: 'tr-full-num', text: String(v) };
|
|
133
|
+
if (t === 'boolean') return { cls: 'tr-full-bool', text: String(v) };
|
|
134
|
+
return { cls: 'tr-full-null', text: String(v) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 深层容器压成单行时的表示:保留"它是个容器"这个事实,不伪装成标量 */
|
|
138
|
+
function oneLine(v) {
|
|
139
|
+
const kind = Array.isArray(v) ? 'array' : 'object';
|
|
140
|
+
const s = safeStringify(v).replace(/\s+/g, ' ');
|
|
141
|
+
const clipped = s.length > MAX_STR ? s.slice(0, MAX_STR) + '…' : s;
|
|
142
|
+
return `<${kind} · ${sizeOf(v)} 项> ${clipped}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 「字符串里套着 JSON」:返回解析出来的容器,其余(普通文本、标量字符串、坏 JSON)一律 `null`。
|
|
147
|
+
*
|
|
148
|
+
* 判据是**首尾字符**而不是「parse 成功」,这一条是这段代码的全部要害:`"123"`、`"null"`、
|
|
149
|
+
* `"true"` 都能被 `JSON.parse` 吃下去,但把它们显示成数字/空值/布尔,等于把工具返回的
|
|
150
|
+
* 字符串改写成另一种类型 —— 调试面板的第一职责是如实。所以先看 trim 后的首尾一个字符,
|
|
151
|
+
* 不吻合就连 parse 都不试(普通文本占绝大多数,省掉无谓的反序列化)。
|
|
152
|
+
* 截断过的 JSON(尾部被切掉)在这里自然失败,回落成原样字符串。
|
|
153
|
+
*/
|
|
154
|
+
function jsonInString(v) {
|
|
155
|
+
if (typeof v !== 'string' || v.length < 2 || v.length > MAX_JSON_STR) return null;
|
|
156
|
+
const t = v.trim();
|
|
157
|
+
if (t.length < 2) return null;
|
|
158
|
+
const head = t[0];
|
|
159
|
+
const tail = t[t.length - 1];
|
|
160
|
+
if (!((head === '{' && tail === '}') || (head === '[' && tail === ']'))) return null;
|
|
161
|
+
try {
|
|
162
|
+
// 首尾判据已经保证了这里解析出来的必然是对象或数组(`{`…`}` / `[`…`]`),
|
|
163
|
+
// 所以不再重复判类型 —— 那会是一条永远走不到的防御分支(变异测试实测盖不住)。
|
|
164
|
+
return JSON.parse(t);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 「这个字符串里装着 JSON」的标记。**必须显眼**:不标出来,读者会以为工具真的返回了对象。
|
|
171
|
+
* title 顺带交代两件容易误会的事 —— 原文还在(`.tr-io` 与块内原值),路径需先 parse 这一层。 */
|
|
172
|
+
function jsonBadge() {
|
|
173
|
+
const b = fvEl('span', 'tr-full-json', 'str→json');
|
|
174
|
+
b.title = '这个值是字符串,内容是 JSON(已按对象展示;内部路径需先 JSON.parse 这一层)';
|
|
175
|
+
return b;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 子键的 JSON 路径:对象用 `.key`,数组用 `[i]`。根是空串,所以 `[0]` 不会变成 `.[0]` */
|
|
179
|
+
function pathOf(parent, key, isArr) {
|
|
180
|
+
return isArr ? `${parent}[${key}]` : parent ? `${parent}.${key}` : key;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 剪贴板:没有实现时**什么都不做**,绝不把没做到的事显示成「已复制」 */
|
|
184
|
+
function copyInto(btn, text, done) {
|
|
185
|
+
const clip = globalThis.navigator?.clipboard;
|
|
186
|
+
if (!clip || typeof clip.writeText !== 'function') return;
|
|
187
|
+
Promise.resolve(clip.writeText(text)).then(
|
|
188
|
+
() => {
|
|
189
|
+
btn.textContent = done;
|
|
190
|
+
},
|
|
191
|
+
() => {
|
|
192
|
+
btn.textContent = '复制失败';
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 正在选文本时不要收起/展开 —— 鼠标拖选到行外松手会补一次 click,否则选完就自己合上了 */
|
|
198
|
+
function hasSelection() {
|
|
199
|
+
try {
|
|
200
|
+
return String(globalThis.getSelection?.() ?? '') !== '';
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 悬停才露出的 `⧉`:点它复制路径,**不**触发所在容器的折叠 */
|
|
207
|
+
function pathBtn(path) {
|
|
208
|
+
const btn = fvEl('span', 'tr-full-path', '⧉');
|
|
209
|
+
btn.title = '复制路径 ' + path;
|
|
210
|
+
btn.addEventListener('click', (e) => {
|
|
211
|
+
// 真实 DOM 里会冒泡到容器行的折叠处理;stub 的 click() 传的是空对象,故双保险
|
|
212
|
+
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
|
|
213
|
+
copyInto(btn, path, '已复制');
|
|
214
|
+
});
|
|
215
|
+
return btn;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 这一层要不要铺开。显式折叠/展开优先(`fold` 里有记录),否则按深度给默认值。
|
|
220
|
+
* @param {Map<string, boolean>} fold 路径 → 是否展开
|
|
221
|
+
*/
|
|
222
|
+
function isOpen(fold, path, depth) {
|
|
223
|
+
return fold.has(path) ? fold.get(path) === true : depth < OPEN_DEPTH;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 同构对象数组 → 列名;不是"真同构"就返回 null(调用方回落树形)。
|
|
228
|
+
*
|
|
229
|
+
* 判据全部是「表格会丢东西」的地方,松一条就丢一类数据:元素必须是普通对象、
|
|
230
|
+
* 键集**完全一致**(缺键/多键都回落)、列数 1..6、每列都是标量(表格里没法逐层展开)。
|
|
231
|
+
*/
|
|
232
|
+
function tableColsOf(arr) {
|
|
233
|
+
if (!Array.isArray(arr) || arr.length < 2 || arr.length > MAX_ITEMS) return null;
|
|
234
|
+
if (!isPlainObject(arr[0])) return null;
|
|
235
|
+
const cols = Object.keys(arr[0]);
|
|
236
|
+
if (cols.length === 0 || cols.length > MAX_COLS) return null;
|
|
237
|
+
for (const item of arr) {
|
|
238
|
+
if (!isPlainObject(item)) return null;
|
|
239
|
+
if (Object.keys(item).length !== cols.length) return null;
|
|
240
|
+
for (const c of cols) {
|
|
241
|
+
if (!Object.hasOwn(item, c)) return null;
|
|
242
|
+
if (isStructured(item[c])) return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return cols;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 同构数组铺成表格。缩进用 `data-depth`(值为 0 时不写)—— CSS 靠属性选择器补左外边距,
|
|
250
|
+
* 因为列数是动态的、行数也是动态的,没有一个能写死的 inline style 可靠活在 stub 里。
|
|
251
|
+
*/
|
|
252
|
+
function renderTable(ctx, arr, cols, depth, path) {
|
|
253
|
+
const table = fvEl('table', 'tr-full-table');
|
|
254
|
+
if (depth > 0) table.dataset.depth = String(depth);
|
|
255
|
+
|
|
256
|
+
const thead = fvEl('thead');
|
|
257
|
+
const headRow = fvEl('tr');
|
|
258
|
+
for (const c of cols) {
|
|
259
|
+
const th = fvEl('th');
|
|
260
|
+
th.appendChild(fvEl('span', 'tr-full-col', c));
|
|
261
|
+
// 列的路径带 `[*]`:复制出去能直接用于过滤/取值,而不是某一行的那一份
|
|
262
|
+
th.appendChild(pathBtn(pathOf(path + '[*]', c, false)));
|
|
263
|
+
headRow.appendChild(th);
|
|
264
|
+
}
|
|
265
|
+
thead.appendChild(headRow);
|
|
266
|
+
table.appendChild(thead);
|
|
267
|
+
|
|
268
|
+
const tbody = fvEl('tbody');
|
|
269
|
+
for (const item of arr) {
|
|
270
|
+
const tr = fvEl('tr');
|
|
271
|
+
for (const c of cols) {
|
|
272
|
+
const td = fvEl('td');
|
|
273
|
+
const p = primitive(item[c]); // 表里同样截断并说出剩余(不做「表格例外」)
|
|
274
|
+
td.appendChild(fvEl('span', p.cls, p.text));
|
|
275
|
+
tr.appendChild(td);
|
|
276
|
+
}
|
|
277
|
+
tbody.appendChild(tr);
|
|
278
|
+
}
|
|
279
|
+
table.appendChild(tbody);
|
|
280
|
+
ctx.body.appendChild(table);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 缩进空格的落点:0 层不占位(否则每条顶级键都白带两个空格) */
|
|
284
|
+
function indentOf(depth) {
|
|
285
|
+
return depth > 0 ? fvEl('span', 'tr-full-indent', ' '.repeat(depth)) : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 折叠箭头的槽位。叶子行也占一格(传空串),否则同层键名会在「有箭头」与「没箭头」之间
|
|
289
|
+
* 左右跳 10px —— 一列键名对齐是这类面板最基本的可读性,不能靠行首参差来区分有没有子项。 */
|
|
290
|
+
function caretSlot(glyph) {
|
|
291
|
+
return fvEl('span', 'tr-full-caret', glyph);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 一行 = 缩进 + 箭头槽位(+ 稍后追加的键名/值)。所有行都从同一个入口起头,
|
|
295
|
+
* 这样才能保证「同一层的键名在同一条竖线上」—— 逐处手写就迟早漏一处。 */
|
|
296
|
+
function startRow(depth, caret) {
|
|
297
|
+
const row = fvEl('div', 'tr-full-line');
|
|
298
|
+
const indent = indentOf(depth);
|
|
299
|
+
if (indent) row.appendChild(indent);
|
|
300
|
+
row.appendChild(caretSlot(caret));
|
|
301
|
+
return row;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 把一个值铺进 `ctx.body`。
|
|
306
|
+
* @param {{body: Element, fold: Map<string, boolean>, repaint: () => void}} ctx
|
|
307
|
+
* @param {any} v 值
|
|
308
|
+
* @param {number} depth 当前缩进层级(根为 0)
|
|
309
|
+
* @param {string} path 当前值的 JSON 路径(根为空串)
|
|
310
|
+
* @param {boolean} [allowParse] 还允许不允从字符串里解析 JSON(进了「解析出来的子树」就为 false)
|
|
311
|
+
*/
|
|
312
|
+
function renderInto(ctx, v, depth, path, allowParse = true) {
|
|
313
|
+
const box = ctx.body;
|
|
314
|
+
|
|
315
|
+
if (!isStructured(v)) {
|
|
316
|
+
const p = primitive(v);
|
|
317
|
+
const row = startRow(depth, '');
|
|
318
|
+
row.appendChild(fvEl('span', p.cls, p.text));
|
|
319
|
+
box.appendChild(row);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const isArr = Array.isArray(v);
|
|
324
|
+
const cols = isArr ? tableColsOf(v) : null;
|
|
325
|
+
if (cols) {
|
|
326
|
+
renderTable(ctx, v, cols, depth, path);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const keys = isArr ? v.map((_, i) => String(i)) : Object.keys(v);
|
|
331
|
+
const show = keys.slice(0, MAX_ITEMS);
|
|
332
|
+
|
|
333
|
+
for (const k of show) {
|
|
334
|
+
const child = isArr ? v[Number(k)] : v[k];
|
|
335
|
+
const keyText = isArr ? `[${k}]` : `${k}:`;
|
|
336
|
+
const childPath = pathOf(path, k, isArr);
|
|
337
|
+
// 字符串里套着的 JSON(`content: "{\"rows\":[...]}"`)⇒ 按容器铺。`allowParse` 只在
|
|
338
|
+
// 「刚从一个字符串里解析出来」的那棵子树上为 false —— 一层套一层会放大成十万个节点。
|
|
339
|
+
const parsed = allowParse ? jsonInString(child) : null;
|
|
340
|
+
const val = parsed ?? child;
|
|
341
|
+
const fromJson = parsed !== null;
|
|
342
|
+
// 「能否折叠」只看三件事:是容器、非空、没到深度上限 —— 与下面几个分支同一套判据。
|
|
343
|
+
// 先算出来是为了让箭头槽位在起行时就有正确字形(叶子行留空格位,键名才会对齐)。
|
|
344
|
+
const foldable = isStructured(val) && sizeOf(val) > 0 && depth < MAX_DEPTH;
|
|
345
|
+
const open = foldable && isOpen(ctx.fold, childPath, depth);
|
|
346
|
+
const row = startRow(depth, foldable ? (open ? '▾' : '▸') : '');
|
|
347
|
+
|
|
348
|
+
// 叶子:同一行里写完(缩进 + 键名 + 路径 + 值)
|
|
349
|
+
if (!isStructured(val)) {
|
|
350
|
+
const p = primitive(child);
|
|
351
|
+
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
352
|
+
row.appendChild(pathBtn(childPath));
|
|
353
|
+
row.appendChild(fvEl('span', p.cls, ' ' + p.text));
|
|
354
|
+
box.appendChild(row);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 深度用尽 ⇒ 整体压成一行(并标明它是个容器),避免缩进吃掉全部宽度
|
|
359
|
+
if (depth >= MAX_DEPTH) {
|
|
360
|
+
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
361
|
+
if (fromJson) row.appendChild(jsonBadge());
|
|
362
|
+
row.appendChild(fvEl('span', 'tr-full-null', ' ' + oneLine(val)));
|
|
363
|
+
box.appendChild(row);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 空容器:留一对括号,别让它看起来像"展开失败"(`"{}"` / `"[]"` 这类字符串也走这里)
|
|
368
|
+
if (sizeOf(val) === 0) {
|
|
369
|
+
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
370
|
+
if (fromJson) row.appendChild(jsonBadge());
|
|
371
|
+
row.appendChild(fvEl('span', 'tr-full-null', Array.isArray(val) ? ' []' : ' {}'));
|
|
372
|
+
box.appendChild(row);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
377
|
+
row.appendChild(pathBtn(childPath));
|
|
378
|
+
if (fromJson) row.appendChild(jsonBadge());
|
|
379
|
+
if (!open) {
|
|
380
|
+
const kind = Array.isArray(val) ? 'array' : 'object';
|
|
381
|
+
row.appendChild(fvEl('span', 'tr-full-hint', ` { ${kind} · ${sizeOf(val)} 项 }`));
|
|
382
|
+
} else if (Array.isArray(val) && tableColsOf(val)) {
|
|
383
|
+
// 成表时行内不铺子项,只在键名后标一句规模 —— 否则 `▾ targets:` 后面直接跟着表格,
|
|
384
|
+
// 看不出这是个数组、也看不出表格是不是全部内容(是不是被截断了)
|
|
385
|
+
row.appendChild(fvEl('span', 'tr-full-hint', ` array · ${val.length} 项`));
|
|
386
|
+
}
|
|
387
|
+
row.dataset.fold = childPath;
|
|
388
|
+
row.addEventListener('click', (e) => {
|
|
389
|
+
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
|
|
390
|
+
if (hasSelection()) return;
|
|
391
|
+
ctx.fold.set(childPath, !open);
|
|
392
|
+
ctx.repaint();
|
|
393
|
+
});
|
|
394
|
+
box.appendChild(row);
|
|
395
|
+
|
|
396
|
+
if (open) renderInto(ctx, val, depth + 1, childPath, fromJson ? false : allowParse);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (keys.length > show.length) {
|
|
400
|
+
const row = startRow(depth, '');
|
|
401
|
+
row.appendChild(fvEl('span', 'tr-full-null', `… 还有 ${keys.length - show.length} 项未展开`));
|
|
402
|
+
box.appendChild(row);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 建一个结构化正文块。
|
|
408
|
+
* @param {any} value `ev.full` 的**原值**(不是序列化后的字符串)
|
|
409
|
+
* @param {{fold?: Map<string, boolean>}} [opts]
|
|
410
|
+
* `fold` 是**调用方持有**的折叠状态(路径 → 是否展开)。省略时本块自建一份,
|
|
411
|
+
* 此时折叠状态活不过下一次「全量重建」—— 只对一次性渲染(测试、静态快照)够用。
|
|
412
|
+
* @returns {Element} `.tr-full` 块;调用方负责 append 到展开行之后
|
|
413
|
+
*/
|
|
414
|
+
function createFullView(value, opts = {}) {
|
|
415
|
+
const fold = opts.fold instanceof Map ? opts.fold : new Map();
|
|
416
|
+
|
|
417
|
+
// 根是字符串 ⇒ 只有「里面装着 JSON」才会进来(调用方用 hasStructuredBody 先筛过)。
|
|
418
|
+
// 头部同时写出**两件事**:它原本是字符串(字符数)、解析后是什么规模 —— 只写后者,
|
|
419
|
+
// 读者会以为工具真的返回了对象。
|
|
420
|
+
const parsedRoot = jsonInString(value);
|
|
421
|
+
const root = parsedRoot ?? value;
|
|
422
|
+
const fromJson = parsedRoot !== null;
|
|
423
|
+
|
|
424
|
+
const box = fvEl('div', 'tr-full');
|
|
425
|
+
box.dataset.kind = Array.isArray(root) ? 'array' : 'object';
|
|
426
|
+
|
|
427
|
+
const head = fvEl('div', 'tr-full-head');
|
|
428
|
+
const label = typeLabelOf(root);
|
|
429
|
+
head.appendChild(
|
|
430
|
+
fvEl('span', 'tr-full-type', fromJson ? `string · ${value.length} 字符 → ${label}` : label),
|
|
431
|
+
);
|
|
432
|
+
if (fromJson) head.appendChild(jsonBadge());
|
|
433
|
+
const copy = fvEl('span', 'tr-full-copy', '复制');
|
|
434
|
+
copy.dataset.copy = '1';
|
|
435
|
+
// 字符串根复制的是**原文**(拿到手能直接喂 jq);容器根复制的是缩进后的 JSON
|
|
436
|
+
copy.addEventListener('click', () =>
|
|
437
|
+
copyInto(copy, fromJson ? String(value) : safeStringify(root), '已复制'),
|
|
438
|
+
);
|
|
439
|
+
head.appendChild(copy);
|
|
440
|
+
box.appendChild(head);
|
|
441
|
+
|
|
442
|
+
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();
|
|
451
|
+
|
|
452
|
+
box.appendChild(body);
|
|
453
|
+
return box;
|
|
454
|
+
}
|
|
455
|
+
|
|
5
456
|
/* ===== src/view.js ===== */
|
|
6
457
|
/* Agentia trace 调用树渲染器 —— 数据无关、零依赖。
|
|
7
458
|
*
|
|
@@ -22,6 +473,16 @@
|
|
|
22
473
|
* 宿主需提供的 CSS 变量:--ice --faint --text(见 trace-view.css)。
|
|
23
474
|
*/
|
|
24
475
|
|
|
476
|
+
/* 展开态的正文有两种形状,由 `full` 的类型决定(2026-11-11,2026-11-12 扩了一档):
|
|
477
|
+
* - 纯文本字符串 → 老路:`.tr-io` 里放原文,靠 `.tr-open` 换行(纯文本正文、官网模拟回放);
|
|
478
|
+
* - 对象/数组 → 追加一个 `.tr-full` 结构化块(折叠 / 成表 / 路径复制),`.tr-io` 由 CSS 隐藏;
|
|
479
|
+
* - **装着 JSON 的字符串**(`content: "{\"rows\":[...]}"`,工具把结构序列化后塞回文本字段)
|
|
480
|
+
* → 同上一档,块头写明「string · N 字符 → object · M 项」并打 `str→json` 标记。
|
|
481
|
+
* 判据与块内完全同源(`hasStructuredBody`),「挂不挂块」与「块里画什么」因此不会跑偏。
|
|
482
|
+
* 前两档并存是刻意的:纯文本那条路有 test/style.test.js 的两个不变量守着(最小宽度、caret 常显),
|
|
483
|
+
* 全换成本件会让那两个守卫失去落点。 */
|
|
484
|
+
|
|
485
|
+
|
|
25
486
|
const CAP_ICO = { tool: '⚙', skill: '◆', prompt: '¶', subagent: '⊕' };
|
|
26
487
|
|
|
27
488
|
/** 能力类型取自名字前缀(tool: / skill: / prompt: / subagent:) */
|
|
@@ -93,6 +554,9 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
93
554
|
/* 展开态的行 key 集合 + 事件 key 发号器。两者都必须活在 renderTrace 之外:
|
|
94
555
|
渲染是【全量重建】(rootEl.innerHTML = ''),状态放进 DOM 或渲染过程里就活不过下一次事件。 */
|
|
95
556
|
const expanded = new Set();
|
|
557
|
+
/* 结构化块内部的折叠状态,按事件键一份(`ev.key` → 路径 → 是否展开)。
|
|
558
|
+
与 `expanded` 同一条理由,而且更硬:块本身每次重建都会 new 一个,状态只能挂在树上。 */
|
|
559
|
+
const folds = new Map();
|
|
96
560
|
let evSeq = 0;
|
|
97
561
|
|
|
98
562
|
/** 正在选文本时不要收起/展开 —— 鼠标拖选到行外松手会补一次 click,否则选完就自己合上了 */
|
|
@@ -141,20 +605,36 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
141
605
|
// 非 tool.* 事件(usage.unpriced / llm.retry / budget.* / context.budget)没有工具名 ——
|
|
142
606
|
// 这一格整个不渲染,别把「无工具」显示成假工具名(.tr-name 是 flex:none,.tr-io 自然占满)
|
|
143
607
|
if (ev.tool) row.appendChild(el('span', 'tr-name', ev.tool));
|
|
144
|
-
// 展开态用全文(ev.full),折叠态用摘要(ev.text)——
|
|
145
|
-
|
|
608
|
+
// 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致。
|
|
609
|
+
// `full` 是对象/数组、或**装着 JSON 的字符串**时(2026-11-11 / 2026-11-12):`.tr-io`
|
|
610
|
+
// 仍放**原文**(结构化块挂在行后、由 CSS 隐藏本格),所以「展开拿到的是原文而不是
|
|
611
|
+
// 62 字符摘要」这条不变量原样成立。
|
|
612
|
+
const structured = hasStructuredBody(ev.full);
|
|
613
|
+
const fullText = structured ? rawArg(ev.full) : ev.full || '';
|
|
614
|
+
const io = el('span', 'tr-io', (open ? fullText : ev.text) || '');
|
|
146
615
|
// 展开后正文已在行里,再挂一个占满整行的原生 tooltip 只会挡视线
|
|
147
616
|
io.title = open ? '' : ev.text || '';
|
|
148
617
|
row.appendChild(io);
|
|
149
618
|
if (ev.full) {
|
|
150
619
|
row.dataset.expandable = '1'; // 可展开标记:CSS 靠它出 caret / cursor,测试靠它找行
|
|
151
620
|
row.appendChild(el('span', 'tr-caret', open ? '▾' : '▸'));
|
|
152
|
-
if (open) row.className += ' tr-open';
|
|
621
|
+
if (open) row.className += ' tr-open' + (structured ? ' tr-structured' : '');
|
|
153
622
|
row.addEventListener('click', () => {
|
|
154
623
|
if (!hasSelection()) toggleExpand(ev.key);
|
|
155
624
|
});
|
|
156
625
|
}
|
|
157
626
|
rootEl.appendChild(row);
|
|
627
|
+
// 结构化正文是**兄弟块**而不是 `.tr-io` 的子节点:行是 flex + nowrap,
|
|
628
|
+
// 塞进去会把树的列对齐打乱;独立成块才能自己换行、自己缩进。
|
|
629
|
+
// 折叠状态按事件键取一份带进去(2026-11-11 晚):块每次重建都 new,状态不能放块里。
|
|
630
|
+
if (open && structured) {
|
|
631
|
+
let fold = folds.get(ev.key);
|
|
632
|
+
if (!fold) {
|
|
633
|
+
fold = new Map();
|
|
634
|
+
folds.set(ev.key, fold);
|
|
635
|
+
}
|
|
636
|
+
rootEl.appendChild(createFullView(ev.full, { fold }));
|
|
637
|
+
}
|
|
158
638
|
return;
|
|
159
639
|
}
|
|
160
640
|
|
|
@@ -229,6 +709,7 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
229
709
|
spanMap.clear();
|
|
230
710
|
spanMap.set('root', traceRoot);
|
|
231
711
|
expanded.clear(); // 展开态属于上一棵树:reset 开新树时一并清掉
|
|
712
|
+
folds.clear(); // 块内折叠同理:留着只会在下一次点开时出现"莫名已折叠"的状态
|
|
232
713
|
usageAcc.input = 0;
|
|
233
714
|
usageAcc.output = 0;
|
|
234
715
|
usageAcc.cacheRead = 0;
|
|
@@ -409,15 +890,24 @@ function eventText(ev) {
|
|
|
409
890
|
}
|
|
410
891
|
|
|
411
892
|
/**
|
|
412
|
-
*
|
|
893
|
+
* 事件全文(展开态用)—— **给原值,不预先序列化**。
|
|
894
|
+
*
|
|
895
|
+
* 2026-11-11 起 `full` 的类型本身携带语义:字符串 ⇒ 渲染器走 `.tr-io` 的纯文本换行;
|
|
896
|
+
* 对象/数组 ⇒ 交给 `full-view.js` 铺成带缩进的结构化块。所以这里必须把**原值**递下去 ——
|
|
897
|
+
* 若仍在这里 `rawArg` 成字符串,「一大坨 JSON」那条路就永远回不来了(渲染器只能看到字符串)。
|
|
413
898
|
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
899
|
+
* 口径:
|
|
900
|
+
* - `tool.input` → 入参原值(`fmtArg` 的摘要砍到 4 键 / 每值 21 字符 / 整串 62 字符,
|
|
901
|
+
* 拿摘要当全文就是「假展开」);
|
|
902
|
+
* - `tool.output` → `content` 原值:字符串就是纯文本(含图片块时会是非字符串,正好结构化);
|
|
903
|
+
* - 其余事件 → `eventText` 的字符串(它们的体本来就是给人看的一小段说明)。
|
|
418
904
|
*/
|
|
419
905
|
function eventFull(ev) {
|
|
420
|
-
if (ev.name === 'tool.input') return
|
|
906
|
+
if (ev.name === 'tool.input') return ev.body?.input ?? '';
|
|
907
|
+
if (ev.name === 'tool.output') {
|
|
908
|
+
const c = ev.body?.content;
|
|
909
|
+
return c == null ? '' : c;
|
|
910
|
+
}
|
|
421
911
|
return eventText(ev);
|
|
422
912
|
}
|
|
423
913
|
|
|
@@ -680,13 +1170,24 @@ function escapeHtml(s) {
|
|
|
680
1170
|
/* @migor/trace-view —— Agentia trace 调用树渲染器(框架无关、零依赖)。
|
|
681
1171
|
* 官网 playground 与 CLI inspector 共用同一份,避免两处渲染漂移。 */
|
|
682
1172
|
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
/* 结构化正文块(2026-11-11):`full` 是容器时,渲染器会给展开行挂上这个块;
|
|
1177
|
+
* 2026-11-12 起「装着 JSON 的字符串」也走这条路(`hasStructuredBody` 是同一个判据,
|
|
1178
|
+
* 宿主自己判断要不要挂块时用得上)。单独导出是因为它是**纯函数式的 DOM 构造**
|
|
1179
|
+
* (入参 → 元素),宿主可以拿它渲染任意值 —— 而 bundle 的导出面必须与这里逐字一致
|
|
1180
|
+
* (`test/bundle.test.js`),所以内部件不进公共面、这两件必须进。 */
|
|
1181
|
+
|
|
683
1182
|
export {
|
|
684
1183
|
CAP_ICO,
|
|
685
1184
|
capabilityTypeOf,
|
|
1185
|
+
createFullView,
|
|
686
1186
|
createTraceView,
|
|
687
1187
|
fmtArg,
|
|
688
1188
|
fmtMs,
|
|
689
1189
|
fmtNum,
|
|
1190
|
+
hasStructuredBody,
|
|
690
1191
|
playTrace,
|
|
691
1192
|
rawArg,
|
|
692
1193
|
renderSummary,
|