@migor/cli 0.10.7 → 0.10.9
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 +92 -1
- package/dist/inspector/full-view.js +132 -23
- package/dist/inspector/index.js +6 -5
- package/dist/inspector/trace-view.bundle.js +174 -38
- package/dist/inspector/trace-view.css +23 -0
- package/dist/inspector/view.js +37 -12
- package/dist/inspector-page.html +71 -2
- package/dist/markdown.js +170 -6
- package/dist/templates/package.json +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,95 @@
|
|
|
5
5
|
(0.x 阶段:minor 可含破坏性变更,每个破坏性变更都在对应版本的「迁移」小节里写明)。
|
|
6
6
|
决策的完整证据链在 `docs/spec.md` §10(带时间线的决策日志)。
|
|
7
7
|
|
|
8
|
+
## [0.10.9] - 2026-11-12
|
|
9
|
+
|
|
10
|
+
### 变更
|
|
11
|
+
|
|
12
|
+
调试面板的**两个渲染面各补一格**:右栏「回复正文」支持 GFM 表格,调用树展开的工具出参能把
|
|
13
|
+
**一整篇 Markdown** 渲染出来。无破坏性变更、无迁移动作。两包同步 0.10.8 → 0.10.9;
|
|
14
|
+
`@migor/agentia` 此次仍只有版本常量变化(`src/` 一行未动),实际改动全在 CLI 面板与
|
|
15
|
+
trace-view 渲染件。
|
|
16
|
+
|
|
17
|
+
- **两个面缺的是两件事,来源是同一份真实素材**:右栏正文里 `###` 渲染出来了、表格却是一行行
|
|
18
|
+
`| # | 错误内容 |` 加 `|---|`;调用树里展开的 `tool.output` 干脆整篇是源文
|
|
19
|
+
(`<!-- 迁移自 … -->`、`# JS 错误巡检`、`|---|---|` 全在)。
|
|
20
|
+
- **补 GFM 表格,并推翻当年那条「不做表格」的判断**:`markdown.ts` 头注当时明写「不做表格 /
|
|
21
|
+
任务列表 / 嵌套列表 —— 面板是调试视图,不是文档站」。**那个判断是错的**:agent 的产出里
|
|
22
|
+
「错误汇总 / 逐项统计 / 参数对照」天然就是表,砍掉它等于把最有信息量的几种结果打回源文,
|
|
23
|
+
读者被迫在脑子里做列对齐。
|
|
24
|
+
- **成表的判据要两行同时成立、且列数相等**:只要「行里有 `|`」就成表,`a | b` 这种正文会被
|
|
25
|
+
吞成表头;只要「下一行像分隔行」就成表,`---` 分隔线下面那段会被吃掉。列按表头对齐
|
|
26
|
+
(缺列补空、多列丢掉),`\|` 是格子里的字面竖线,对齐三档走 `:--` / `--:` / `:--:`,
|
|
27
|
+
表格后紧跟的非 `|` 行立刻收尾(它没有围栏)。
|
|
28
|
+
- **HTML 注释丢弃、可跨行**:真实素材开头三四行常是 `<!-- 迁移自 … -->` 这类作者旁注,让它们
|
|
29
|
+
以字面文本占掉首屏比不渲染更糟。**原文一个字节都不改** —— 隐藏只发生在渲染视图里;
|
|
30
|
+
**码块里的 `<!-- -->` 保留**:注释是注释,代码是代码。
|
|
31
|
+
- **一整篇 Markdown 现在是「字符串」,原先没有任何落点**:SKILL.md、巡检报告这类工具出参展开后
|
|
32
|
+
是源文。现在走宿主注入 `createTraceView(root, { textBody })` —— 本包单独发布,反向
|
|
33
|
+
依赖 CLI 不可能;让它自带一份解析器,仓库里就有两份必然漂移的。
|
|
34
|
+
- **「像不像 Markdown」的判据只认块级记号、且要两分**(围栏 / 表格各 2,标题 / 列表 / 引用各 1)。
|
|
35
|
+
**刻意不看行内记号**:`**kwargs` / `*args` 是 Python 源码里最常见的东西,按行内判会把整份
|
|
36
|
+
源码判成 Markdown,而段落合并会**毁掉它的缩进**。超 100k 不渲染。判错的代价不是「少渲染」,
|
|
37
|
+
是把一段本来好读的文本毁掉 ⇒ 宁漏不错(漏了还能看原文)。
|
|
38
|
+
- **注入的正文只负责画、不再铺树**:`createFullView(value, { body })` 给了 body 就原样挂,头部
|
|
39
|
+
写 `string · N 字符` + `md` 标记(不与标记重复),「复制」取原文,`.tr-io` 仍持原文 ——
|
|
40
|
+
与结构化块走**同一条挂载路径**,否则「哪种正文」会从外观上分叉成两套。
|
|
41
|
+
- **`textBody` 只在展开时调用、宿主抛错回落原文**:折叠态每条事件都重画整棵树,挂在那儿等于
|
|
42
|
+
每来一条事件就把所有历史正文白解析一遍;调试面板不该因为一条坏正文白屏。
|
|
43
|
+
- ⚠️ **官网 Playground 不注入**(那里的工具出参保持原文)。这个不对称是**有意**的,写在
|
|
44
|
+
spec 里 —— 不写清楚就会在某个 PR 里被当成缺陷「顺手修掉」。
|
|
45
|
+
|
|
46
|
+
### 新增:面板里的 Markdown(2026-11-12 ref-md-in-panel)
|
|
47
|
+
|
|
48
|
+
- 决策见 `docs/spec.md` §10 2026-11-12 ref-md-in-panel,守卫登记见 `docs/guards.md` §1。
|
|
49
|
+
- **验证**:CLI 252 → 257(表格 2、三种「像但不是」1、注释 1、接线 1)、trace-view
|
|
50
|
+
90 → 96(注入件 2、接线 4)。**反向验证 8 条各自咬住**:去掉列数相等 / 去掉 `\|` 转义 /
|
|
51
|
+
去掉跨行注释状态 / 把判据阈值降到 1;折叠态也调 `textBody` / 不把 body 交给块 / 忽略注入 /
|
|
52
|
+
去掉宿主抛错的兜底。
|
|
53
|
+
- **顺带修一处守卫腐化**:trace-view 的「基线表不许重复键」用例原先按 `+ 2000` 字符切窗口,
|
|
54
|
+
表一长尾部条目就滑出射程而断言照样绿 —— 正是「守卫看起来在管、实际不管」。改成按对象字面量
|
|
55
|
+
的结束位置切。
|
|
56
|
+
- 行数棘轮逐项登记在 `packages/cli/test/structure.test.mjs` 与
|
|
57
|
+
`packages/trace-view/test/structure.test.js`:`markdown.ts` 296→453、`inspector-page.html`
|
|
58
|
+
1493→1562、`view.js` 360→385、`full-view.js` 449→478、`trace-view.css` 375→386。
|
|
59
|
+
|
|
60
|
+
## [0.10.8] - 2026-11-12
|
|
61
|
+
|
|
62
|
+
### 变更
|
|
63
|
+
|
|
64
|
+
调试面板展开态的**第二档**:字符串里套着的 JSON 也铺开。无破坏性变更、无迁移动作。
|
|
65
|
+
两包同步 0.10.7 → 0.10.8;`@migor/agentia` 此次仍只有版本常量变化(`src/` 未动)。
|
|
66
|
+
|
|
67
|
+
- **上一档解决的是「容器长什么样」,但真实 payload 里最常见的那一层不是容器**:工具把结构
|
|
68
|
+
`JSON.stringify` 后塞回文本字段(`tool.output.content`、MCP 的 text block、SSE 的 `data:`、
|
|
69
|
+
`content[0].text`)。这些值的类型是字符串 ⇒ 走纯文本老路 ⇒ 展开后是一串 `{\"a\":1`
|
|
70
|
+
的反斜杠汤,比 0.10.7 之前那个「一坨」更劝退。
|
|
71
|
+
- **现在这类字符串也按容器铺开**,但**如实标出它原本是字符串**:行内打 `str→json` 标记,
|
|
72
|
+
根级头部写 `string · 232 字符 → object · 3 项`。只写解析后的规模,读者会以为工具真返回了对象。
|
|
73
|
+
- **判据是首尾字符,不是「`JSON.parse` 成功」**:`"123"` / `"null"` / `"true"` 都能 parse,
|
|
74
|
+
把它们显示成数字 / 空值 / 布尔就是**改写工具返回的事实**。trim 后首尾须是 `{`…`}` 或
|
|
75
|
+
`[`…`]`,不吻合连 parse 都不试(普通文本占绝大多数,负路径不付反序列化成本);超 100k 字符
|
|
76
|
+
不解析。
|
|
77
|
+
- **只解析一层**:进了「解析出来的子树」就不再往下解析。一层套一层是真实现象,逐层展开会把
|
|
78
|
+
10KB 放大成十万个节点。
|
|
79
|
+
- **表格里的格子不解析**:同构表已经放弃逐层展开,格子里放不下嵌套块。
|
|
80
|
+
- 字符串根上点「复制」拿到的是**原始字符串**(能直接喂 `jq`),不是重排后的 JSON。
|
|
81
|
+
|
|
82
|
+
### 新增:字符串里的 JSON 也铺开(2026-11-12 ref-trace-json-string)
|
|
83
|
+
|
|
84
|
+
- **判据同源**:新导出 `hasStructuredBody(v)` 与块内共用同一份 `jsonInString`;`view.js` 用它
|
|
85
|
+
取代原先的 `typeof v === 'object'` ⇒「挂不挂块」与「块里画什么」不会分叉。
|
|
86
|
+
`createFullView` 与 `hasStructuredBody` 都进公共导出(bundle 导出面必须与 `src/index.js`
|
|
87
|
+
逐字一致,有 `test/bundle.test.js` 守着)。
|
|
88
|
+
- **宿主侧无需改动**:本次没有新增静态件 —— `full-view.js` / `view.js` / `index.js` 都已在
|
|
89
|
+
CLI inspector 的 STATIC 白名单里,`dist/inspector` 随构建更新即可。
|
|
90
|
+
- **验证**:trace-view 测试 75 → 90(+15);反向验证 6 条各自咬住(摘标记 ⇒ 7 条红;
|
|
91
|
+
去「只解析一层」⇒ 嵌套用例红;接线退回「只认容器」⇒ 字符串出参挂块用例红;去 100k 上限 ⇒
|
|
92
|
+
超长用例红;去首尾先筛 ⇒ parse 计数与谓词两条红;头部只写解析后类型 ⇒ 头部用例红)。
|
|
93
|
+
另记一条**等价变异**的教训:第一版里「parse 结果再判类型」那段代码永远走不到(首尾判据
|
|
94
|
+
已经挡住标量字符串),任何用例都咬不住,已删除 —— 留着只会让下一个人以为那里有守卫。
|
|
95
|
+
决策见 `docs/spec.md` §10 2026-11-12 ref-trace-json-string,守卫登记见 `docs/guards.md` §1。
|
|
96
|
+
|
|
8
97
|
## [0.10.7] - 2026-11-12
|
|
9
98
|
|
|
10
99
|
### 变更
|
|
@@ -4472,7 +4561,9 @@ toolkit 142 / runtime 55 / engine 403 / container 10 / store 55 —— 全绿。
|
|
|
4472
4561
|
首个公开发布:`@migor/agentia` + `@migor/cli`(scope `@migor/*`),两包版本同步。
|
|
4473
4562
|
框架本体单包;CLI 独立成包(workspaces)。
|
|
4474
4563
|
|
|
4475
|
-
[Unreleased]: https://github.com/migoric/agentia/compare/v0.10.
|
|
4564
|
+
[Unreleased]: https://github.com/migoric/agentia/compare/v0.10.9...HEAD
|
|
4565
|
+
[0.10.9]: https://github.com/migoric/agentia/releases/tag/v0.10.9
|
|
4566
|
+
[0.10.8]: https://github.com/migoric/agentia/releases/tag/v0.10.8
|
|
4476
4567
|
[0.10.7]: https://github.com/migoric/agentia/releases/tag/v0.10.7
|
|
4477
4568
|
[0.10.6]: https://github.com/migoric/agentia/releases/tag/v0.10.6
|
|
4478
4569
|
[0.10.5]: https://github.com/migoric/agentia/releases/tag/v0.10.5
|
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
* 吃掉 9 行。同构且各列都是标量时改成表格,表头一行、数据一行。
|
|
17
17
|
* 3. **路径拿不到**:想引用 `results[0].name` 只能用手数层级。每个键悬停出一个 `⧉`,
|
|
18
18
|
* 点一下把路径写进剪贴板(表头写的是 `results[*].name`,因为它是一整列)。
|
|
19
|
+
* 4. **字符串里套着的 JSON 读不了**(2026-11-12):MCP 的 text block、SSE 的 `data:`、把结果
|
|
20
|
+
* 序列化后又塞回 `content` 的工具 —— 这些值的类型是**字符串**,此前只显示成一串
|
|
21
|
+
* `{\"a\":1` 的反斜杠汤,比无缩进的 JSON 更难读。现在首尾是 `{`…`}` / `[`…`]` 且解析成功的
|
|
22
|
+
* 字符串按容器铺开,并在行里打 `str→json` 标记 —— 不标出来,读者会以为工具真的返回了对象。
|
|
23
|
+
* 5. **一整篇 Markdown 的字符串正文**(2026-11-12 晚):工具出参的另一种常见形态就是一份
|
|
24
|
+
* SKILL.md / 错误汇总报告,类型仍是字符串 ⇒ 展开后是 `# 标题`、`|---|---|`、
|
|
25
|
+
* `<!-- 迁移自 … -->` 的源文。本件**不**自带解析器 —— 那会是仓库里的第二份,必然与宿主
|
|
26
|
+
* 那份漂移,所以只接受宿主渲染好的元素(`opts.body`),自己负责头部
|
|
27
|
+
* (`string · N 字符 · md` + `md` 标记)与「复制取原文」。
|
|
19
28
|
*
|
|
20
29
|
* ## 边界(都是「不这么做就会坏」的地方,不是口味)
|
|
21
30
|
*
|
|
@@ -35,6 +44,13 @@
|
|
|
35
44
|
* 会原样复现(这正是 `view.js` 的 `expanded` 集合存在的原因)。
|
|
36
45
|
* - **表格化只在"真的同构"时发生**:元素必须全是普通对象、键集完全一致、列数 1..6、
|
|
37
46
|
* 每列都是标量。任一不满足就回落树形 —— 表格里没法逐层展开,判错一次就是**丢数据**。
|
|
47
|
+
* - **字符串里的 JSON 只解析一层,且必须标出来**:判据是**首尾字符**(`{`…`}` / `[`…`]`),
|
|
48
|
+
* 不是「`JSON.parse` 成功」—— `"123"` / `"null"` / `"true"` 都能 parse 成功,显示成
|
|
49
|
+
* 数字/空值/布尔就是**改写工具返回的事实**。解析结果内部的字符串**不再二次解析**:
|
|
50
|
+
* 一层套一层是业务里的真实现象(SSE data 里再套 JSON),逐层展开会让 10KB 变成十万个节点。
|
|
51
|
+
* 表格单元格也不解析 —— 同构表已经放弃了逐层展开,单元格里放不下嵌套块。
|
|
52
|
+
* - **宿主正文不再解析**:`opts.body` 给了就**原样用**。同一段内容既渲染又铺树,读者会看到
|
|
53
|
+
* 两套互相矛盾的呈现 —— 那比不做这一档更糟。所以这条判据只有一处(`createFullView`)。
|
|
38
54
|
*
|
|
39
55
|
* 零依赖、纯 DOM 构造(只用 createElement / appendChild / textContent / className /
|
|
40
56
|
* dataset / innerHTML=''),与 view.js 共用同一套 DOM stub 跑测试。
|
|
@@ -50,6 +66,8 @@ const MAX_DEPTH = 6;
|
|
|
50
66
|
const OPEN_DEPTH = 2;
|
|
51
67
|
/** 表格最多几列:再多横向就没法看了,回落树形 */
|
|
52
68
|
const MAX_COLS = 6;
|
|
69
|
+
/** 多长的字符串才值得尝试从里面解析 JSON。再长的话,parse 与随后的渲染成本都不值得 */
|
|
70
|
+
const MAX_JSON_STR = 100_000;
|
|
53
71
|
|
|
54
72
|
function fvEl(tag, cls, text) {
|
|
55
73
|
const node = document.createElement(tag);
|
|
@@ -73,6 +91,18 @@ function isStructured(v) {
|
|
|
73
91
|
return v !== null && typeof v === 'object';
|
|
74
92
|
}
|
|
75
93
|
|
|
94
|
+
/**
|
|
95
|
+
* 有没有「结构化正文」可铺 —— 容器,或者**装着 JSON 的字符串**。
|
|
96
|
+
*
|
|
97
|
+
* 后者是业务里的常态:`tool.output` 的 `content` 常常就是 `JSON.stringify(...)` 的结果
|
|
98
|
+
* (工具把结构序列化后塞回文本字段,MCP 的 text block 更是如此)。此前这种值是字符串 ⇒
|
|
99
|
+
* 走纯文本老路 ⇒ 展开后是一串 `{\"a\":1` 的反斜杠汤。判据与 `jsonInString` 完全同一套,
|
|
100
|
+
* 所以「挂不挂块」与「块里画什么」不会跑偏。
|
|
101
|
+
*/
|
|
102
|
+
export function hasStructuredBody(v) {
|
|
103
|
+
return isStructured(v) || jsonInString(v) !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
76
106
|
/** 普通对象(排除数组与 null)—— 表格化与"容器"判定都只要这一种 */
|
|
77
107
|
function isPlainObject(v) {
|
|
78
108
|
return isStructured(v) && !Array.isArray(v);
|
|
@@ -114,6 +144,47 @@ function oneLine(v) {
|
|
|
114
144
|
return `<${kind} · ${sizeOf(v)} 项> ${clipped}`;
|
|
115
145
|
}
|
|
116
146
|
|
|
147
|
+
/**
|
|
148
|
+
* 「字符串里套着 JSON」:返回解析出来的容器,其余(普通文本、标量字符串、坏 JSON)一律 `null`。
|
|
149
|
+
*
|
|
150
|
+
* 判据是**首尾字符**而不是「parse 成功」,这一条是这段代码的全部要害:`"123"`、`"null"`、
|
|
151
|
+
* `"true"` 都能被 `JSON.parse` 吃下去,但把它们显示成数字/空值/布尔,等于把工具返回的
|
|
152
|
+
* 字符串改写成另一种类型 —— 调试面板的第一职责是如实。所以先看 trim 后的首尾一个字符,
|
|
153
|
+
* 不吻合就连 parse 都不试(普通文本占绝大多数,省掉无谓的反序列化)。
|
|
154
|
+
* 截断过的 JSON(尾部被切掉)在这里自然失败,回落成原样字符串。
|
|
155
|
+
*/
|
|
156
|
+
function jsonInString(v) {
|
|
157
|
+
if (typeof v !== 'string' || v.length < 2 || v.length > MAX_JSON_STR) return null;
|
|
158
|
+
const t = v.trim();
|
|
159
|
+
if (t.length < 2) return null;
|
|
160
|
+
const head = t[0];
|
|
161
|
+
const tail = t[t.length - 1];
|
|
162
|
+
if (!((head === '{' && tail === '}') || (head === '[' && tail === ']'))) return null;
|
|
163
|
+
try {
|
|
164
|
+
// 首尾判据已经保证了这里解析出来的必然是对象或数组(`{`…`}` / `[`…`]`),
|
|
165
|
+
// 所以不再重复判类型 —— 那会是一条永远走不到的防御分支(变异测试实测盖不住)。
|
|
166
|
+
return JSON.parse(t);
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 「这个字符串里装着 JSON」的标记。**必须显眼**:不标出来,读者会以为工具真的返回了对象。
|
|
173
|
+
* title 顺带交代两件容易误会的事 —— 原文还在(`.tr-io` 与块内原值),路径需先 parse 这一层。 */
|
|
174
|
+
function jsonBadge() {
|
|
175
|
+
const b = fvEl('span', 'tr-full-json', 'str→json');
|
|
176
|
+
b.title = '这个值是字符串,内容是 JSON(已按对象展示;内部路径需先 JSON.parse 这一层)';
|
|
177
|
+
return b;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 「这段正文被渲染过」的标记。同样**必须显眼**:Markdown 源文与渲染结果差得很远,
|
|
181
|
+
* 不标出来,读者会以为工具返回的就是排好版的样子(而它返回的是一串 `#` 与 `|`)。 */
|
|
182
|
+
function mdBadge() {
|
|
183
|
+
const b = fvEl('span', 'tr-full-md', 'md');
|
|
184
|
+
b.title = '这个字符串按 Markdown 渲染(原文仍在 .tr-io 与「复制」里,一字未改)';
|
|
185
|
+
return b;
|
|
186
|
+
}
|
|
187
|
+
|
|
117
188
|
/** 子键的 JSON 路径:对象用 `.key`,数组用 `[i]`。根是空串,所以 `[0]` 不会变成 `.[0]` */
|
|
118
189
|
function pathOf(parent, key, isArr) {
|
|
119
190
|
return isArr ? `${parent}[${key}]` : parent ? `${parent}.${key}` : key;
|
|
@@ -246,8 +317,9 @@ function startRow(depth, caret) {
|
|
|
246
317
|
* @param {any} v 值
|
|
247
318
|
* @param {number} depth 当前缩进层级(根为 0)
|
|
248
319
|
* @param {string} path 当前值的 JSON 路径(根为空串)
|
|
320
|
+
* @param {boolean} [allowParse] 还允许不允从字符串里解析 JSON(进了「解析出来的子树」就为 false)
|
|
249
321
|
*/
|
|
250
|
-
function renderInto(ctx, v, depth, path) {
|
|
322
|
+
function renderInto(ctx, v, depth, path, allowParse = true) {
|
|
251
323
|
const box = ctx.body;
|
|
252
324
|
|
|
253
325
|
if (!isStructured(v)) {
|
|
@@ -272,14 +344,19 @@ function renderInto(ctx, v, depth, path) {
|
|
|
272
344
|
const child = isArr ? v[Number(k)] : v[k];
|
|
273
345
|
const keyText = isArr ? `[${k}]` : `${k}:`;
|
|
274
346
|
const childPath = pathOf(path, k, isArr);
|
|
347
|
+
// 字符串里套着的 JSON(`content: "{\"rows\":[...]}"`)⇒ 按容器铺。`allowParse` 只在
|
|
348
|
+
// 「刚从一个字符串里解析出来」的那棵子树上为 false —— 一层套一层会放大成十万个节点。
|
|
349
|
+
const parsed = allowParse ? jsonInString(child) : null;
|
|
350
|
+
const val = parsed ?? child;
|
|
351
|
+
const fromJson = parsed !== null;
|
|
275
352
|
// 「能否折叠」只看三件事:是容器、非空、没到深度上限 —— 与下面几个分支同一套判据。
|
|
276
353
|
// 先算出来是为了让箭头槽位在起行时就有正确字形(叶子行留空格位,键名才会对齐)。
|
|
277
|
-
const foldable = isStructured(
|
|
354
|
+
const foldable = isStructured(val) && sizeOf(val) > 0 && depth < MAX_DEPTH;
|
|
278
355
|
const open = foldable && isOpen(ctx.fold, childPath, depth);
|
|
279
356
|
const row = startRow(depth, foldable ? (open ? '▾' : '▸') : '');
|
|
280
357
|
|
|
281
358
|
// 叶子:同一行里写完(缩进 + 键名 + 路径 + 值)
|
|
282
|
-
if (!isStructured(
|
|
359
|
+
if (!isStructured(val)) {
|
|
283
360
|
const p = primitive(child);
|
|
284
361
|
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
285
362
|
row.appendChild(pathBtn(childPath));
|
|
@@ -291,28 +368,31 @@ function renderInto(ctx, v, depth, path) {
|
|
|
291
368
|
// 深度用尽 ⇒ 整体压成一行(并标明它是个容器),避免缩进吃掉全部宽度
|
|
292
369
|
if (depth >= MAX_DEPTH) {
|
|
293
370
|
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
294
|
-
row.appendChild(
|
|
371
|
+
if (fromJson) row.appendChild(jsonBadge());
|
|
372
|
+
row.appendChild(fvEl('span', 'tr-full-null', ' ' + oneLine(val)));
|
|
295
373
|
box.appendChild(row);
|
|
296
374
|
continue;
|
|
297
375
|
}
|
|
298
376
|
|
|
299
|
-
// 空容器:留一对括号,别让它看起来像"展开失败"
|
|
300
|
-
if (sizeOf(
|
|
377
|
+
// 空容器:留一对括号,别让它看起来像"展开失败"(`"{}"` / `"[]"` 这类字符串也走这里)
|
|
378
|
+
if (sizeOf(val) === 0) {
|
|
301
379
|
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
302
|
-
row.appendChild(
|
|
380
|
+
if (fromJson) row.appendChild(jsonBadge());
|
|
381
|
+
row.appendChild(fvEl('span', 'tr-full-null', Array.isArray(val) ? ' []' : ' {}'));
|
|
303
382
|
box.appendChild(row);
|
|
304
383
|
continue;
|
|
305
384
|
}
|
|
306
385
|
|
|
307
386
|
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
308
387
|
row.appendChild(pathBtn(childPath));
|
|
388
|
+
if (fromJson) row.appendChild(jsonBadge());
|
|
309
389
|
if (!open) {
|
|
310
|
-
const kind = Array.isArray(
|
|
311
|
-
row.appendChild(fvEl('span', 'tr-full-hint', ` { ${kind} · ${sizeOf(
|
|
312
|
-
} else if (Array.isArray(
|
|
390
|
+
const kind = Array.isArray(val) ? 'array' : 'object';
|
|
391
|
+
row.appendChild(fvEl('span', 'tr-full-hint', ` { ${kind} · ${sizeOf(val)} 项 }`));
|
|
392
|
+
} else if (Array.isArray(val) && tableColsOf(val)) {
|
|
313
393
|
// 成表时行内不铺子项,只在键名后标一句规模 —— 否则 `▾ targets:` 后面直接跟着表格,
|
|
314
394
|
// 看不出这是个数组、也看不出表格是不是全部内容(是不是被截断了)
|
|
315
|
-
row.appendChild(fvEl('span', 'tr-full-hint', ` array · ${
|
|
395
|
+
row.appendChild(fvEl('span', 'tr-full-hint', ` array · ${val.length} 项`));
|
|
316
396
|
}
|
|
317
397
|
row.dataset.fold = childPath;
|
|
318
398
|
row.addEventListener('click', (e) => {
|
|
@@ -323,7 +403,7 @@ function renderInto(ctx, v, depth, path) {
|
|
|
323
403
|
});
|
|
324
404
|
box.appendChild(row);
|
|
325
405
|
|
|
326
|
-
if (open) renderInto(ctx,
|
|
406
|
+
if (open) renderInto(ctx, val, depth + 1, childPath, fromJson ? false : allowParse);
|
|
327
407
|
}
|
|
328
408
|
|
|
329
409
|
if (keys.length > show.length) {
|
|
@@ -336,33 +416,62 @@ function renderInto(ctx, v, depth, path) {
|
|
|
336
416
|
/**
|
|
337
417
|
* 建一个结构化正文块。
|
|
338
418
|
* @param {any} value `ev.full` 的**原值**(不是序列化后的字符串)
|
|
339
|
-
* @param {{fold?: Map<string, boolean
|
|
419
|
+
* @param {{fold?: Map<string, boolean>, body?: Element}} [opts]
|
|
340
420
|
* `fold` 是**调用方持有**的折叠状态(路径 → 是否展开)。省略时本块自建一份,
|
|
341
421
|
* 此时折叠状态活不过下一次「全量重建」—— 只对一次性渲染(测试、静态快照)够用。
|
|
422
|
+
* `body` 是**宿主已经渲染好的正文**(如 Markdown):给了就原样用,不再走缩进/折叠,
|
|
423
|
+
* 本件只负责头部(类型 / `md` 标记 / 复制)与挂载位置。
|
|
342
424
|
* @returns {Element} `.tr-full` 块;调用方负责 append 到展开行之后
|
|
343
425
|
*/
|
|
344
426
|
export function createFullView(value, opts = {}) {
|
|
345
427
|
const fold = opts.fold instanceof Map ? opts.fold : new Map();
|
|
428
|
+
/* 宿主渲染好的正文。**判据只此一处**:给了它就不解析、不铺树 —— 否则会同时出现
|
|
429
|
+
两套呈现(宿主渲染的正文 + 本件又在旁边铺一遍原值),那比不渲染更糟。 */
|
|
430
|
+
const injected = opts.body && typeof opts.body === 'object' ? opts.body : null;
|
|
431
|
+
|
|
432
|
+
// 根是字符串 ⇒ 只有「里面装着 JSON」或「宿主认得的正文」才会进来。
|
|
433
|
+
// 头部同时写出**两件事**:它原本是字符串(字符数)、以及它是怎么被展示的 —— 只写后者,
|
|
434
|
+
// 读者会以为工具真的返回了对象 / 排好版的正文。
|
|
435
|
+
const parsedRoot = injected ? null : jsonInString(value);
|
|
436
|
+
const root = parsedRoot ?? value;
|
|
437
|
+
const fromJson = parsedRoot !== null;
|
|
346
438
|
|
|
347
439
|
const box = fvEl('div', 'tr-full');
|
|
348
|
-
box.dataset.kind = Array.isArray(
|
|
440
|
+
box.dataset.kind = injected ? 'md' : Array.isArray(root) ? 'array' : 'object';
|
|
349
441
|
|
|
350
442
|
const head = fvEl('div', 'tr-full-head');
|
|
351
|
-
|
|
443
|
+
const label = typeLabelOf(root);
|
|
444
|
+
let typeText = label;
|
|
445
|
+
if (fromJson) typeText = `string · ${value.length} 字符 → ${label}`;
|
|
446
|
+
// 注入正文时不写「→ md」:紧跟其后的 `md` 标记已经说了这件事,
|
|
447
|
+
// 同一行说两遍只会让人以为其中一个另有含义(字符数才是头部真正要补的信息)
|
|
448
|
+
else if (injected) typeText = `string · ${value.length} 字符`;
|
|
449
|
+
head.appendChild(fvEl('span', 'tr-full-type', typeText));
|
|
450
|
+
if (fromJson) head.appendChild(jsonBadge());
|
|
451
|
+
if (injected) head.appendChild(mdBadge());
|
|
352
452
|
const copy = fvEl('span', 'tr-full-copy', '复制');
|
|
353
453
|
copy.dataset.copy = '1';
|
|
354
|
-
|
|
454
|
+
// 字符串根复制的是**原文**(拿到手能直接喂 jq / 贴回上游);容器根复制的是缩进后的 JSON
|
|
455
|
+
copy.addEventListener('click', () =>
|
|
456
|
+
copyInto(copy, fromJson || injected ? String(value) : safeStringify(root), '已复制'),
|
|
457
|
+
);
|
|
355
458
|
head.appendChild(copy);
|
|
356
459
|
box.appendChild(head);
|
|
357
460
|
|
|
358
461
|
const body = fvEl('div', 'tr-full-body');
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
462
|
+
if (injected) {
|
|
463
|
+
// 宿主正文自带排版;本件的缩进/折叠那一套对散文没有意义(它也没有「路径」可复制)
|
|
464
|
+
body.appendChild(injected);
|
|
465
|
+
} else {
|
|
466
|
+
const ctx = { body, fold, repaint: null };
|
|
467
|
+
// 只重画正文(不动头部):折叠不需要重建整棵树,滚动位置与头部反馈都留着
|
|
468
|
+
ctx.repaint = () => {
|
|
469
|
+
body.innerHTML = '';
|
|
470
|
+
// 根若是从字符串解析来的,它的子树不再二次解析(只解析一层,防嵌套放大)
|
|
471
|
+
renderInto(ctx, root, 0, '', !fromJson);
|
|
472
|
+
};
|
|
473
|
+
ctx.repaint();
|
|
474
|
+
}
|
|
366
475
|
|
|
367
476
|
box.appendChild(body);
|
|
368
477
|
return box;
|
package/dist/inspector/index.js
CHANGED
|
@@ -12,8 +12,9 @@ export {
|
|
|
12
12
|
} from './view.js';
|
|
13
13
|
export { playTrace } from './fromTrace.js';
|
|
14
14
|
export { summarizeTrace, renderSummary } from './summary.js';
|
|
15
|
-
/* 结构化正文块(2026-11-11):`full`
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
15
|
+
/* 结构化正文块(2026-11-11):`full` 是容器时,渲染器会给展开行挂上这个块;
|
|
16
|
+
* 2026-11-12 起「装着 JSON 的字符串」也走这条路(`hasStructuredBody` 是同一个判据,
|
|
17
|
+
* 宿主自己判断要不要挂块时用得上)。单独导出是因为它是**纯函数式的 DOM 构造**
|
|
18
|
+
* (入参 → 元素),宿主可以拿它渲染任意值 —— 而 bundle 的导出面必须与这里逐字一致
|
|
19
|
+
* (`test/bundle.test.js`),所以内部件不进公共面、这两件必须进。 */
|
|
20
|
+
export { createFullView, hasStructuredBody } from './full-view.js';
|
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
* 吃掉 9 行。同构且各列都是标量时改成表格,表头一行、数据一行。
|
|
22
22
|
* 3. **路径拿不到**:想引用 `results[0].name` 只能用手数层级。每个键悬停出一个 `⧉`,
|
|
23
23
|
* 点一下把路径写进剪贴板(表头写的是 `results[*].name`,因为它是一整列)。
|
|
24
|
+
* 4. **字符串里套着的 JSON 读不了**(2026-11-12):MCP 的 text block、SSE 的 `data:`、把结果
|
|
25
|
+
* 序列化后又塞回 `content` 的工具 —— 这些值的类型是**字符串**,此前只显示成一串
|
|
26
|
+
* `{\"a\":1` 的反斜杠汤,比无缩进的 JSON 更难读。现在首尾是 `{`…`}` / `[`…`]` 且解析成功的
|
|
27
|
+
* 字符串按容器铺开,并在行里打 `str→json` 标记 —— 不标出来,读者会以为工具真的返回了对象。
|
|
28
|
+
* 5. **一整篇 Markdown 的字符串正文**(2026-11-12 晚):工具出参的另一种常见形态就是一份
|
|
29
|
+
* SKILL.md / 错误汇总报告,类型仍是字符串 ⇒ 展开后是 `# 标题`、`|---|---|`、
|
|
30
|
+
* `<!-- 迁移自 … -->` 的源文。本件**不**自带解析器 —— 那会是仓库里的第二份,必然与宿主
|
|
31
|
+
* 那份漂移,所以只接受宿主渲染好的元素(`opts.body`),自己负责头部
|
|
32
|
+
* (`string · N 字符 · md` + `md` 标记)与「复制取原文」。
|
|
24
33
|
*
|
|
25
34
|
* ## 边界(都是「不这么做就会坏」的地方,不是口味)
|
|
26
35
|
*
|
|
@@ -40,6 +49,13 @@
|
|
|
40
49
|
* 会原样复现(这正是 `view.js` 的 `expanded` 集合存在的原因)。
|
|
41
50
|
* - **表格化只在"真的同构"时发生**:元素必须全是普通对象、键集完全一致、列数 1..6、
|
|
42
51
|
* 每列都是标量。任一不满足就回落树形 —— 表格里没法逐层展开,判错一次就是**丢数据**。
|
|
52
|
+
* - **字符串里的 JSON 只解析一层,且必须标出来**:判据是**首尾字符**(`{`…`}` / `[`…`]`),
|
|
53
|
+
* 不是「`JSON.parse` 成功」—— `"123"` / `"null"` / `"true"` 都能 parse 成功,显示成
|
|
54
|
+
* 数字/空值/布尔就是**改写工具返回的事实**。解析结果内部的字符串**不再二次解析**:
|
|
55
|
+
* 一层套一层是业务里的真实现象(SSE data 里再套 JSON),逐层展开会让 10KB 变成十万个节点。
|
|
56
|
+
* 表格单元格也不解析 —— 同构表已经放弃了逐层展开,单元格里放不下嵌套块。
|
|
57
|
+
* - **宿主正文不再解析**:`opts.body` 给了就**原样用**。同一段内容既渲染又铺树,读者会看到
|
|
58
|
+
* 两套互相矛盾的呈现 —— 那比不做这一档更糟。所以这条判据只有一处(`createFullView`)。
|
|
43
59
|
*
|
|
44
60
|
* 零依赖、纯 DOM 构造(只用 createElement / appendChild / textContent / className /
|
|
45
61
|
* dataset / innerHTML=''),与 view.js 共用同一套 DOM stub 跑测试。
|
|
@@ -55,6 +71,8 @@ const MAX_DEPTH = 6;
|
|
|
55
71
|
const OPEN_DEPTH = 2;
|
|
56
72
|
/** 表格最多几列:再多横向就没法看了,回落树形 */
|
|
57
73
|
const MAX_COLS = 6;
|
|
74
|
+
/** 多长的字符串才值得尝试从里面解析 JSON。再长的话,parse 与随后的渲染成本都不值得 */
|
|
75
|
+
const MAX_JSON_STR = 100_000;
|
|
58
76
|
|
|
59
77
|
function fvEl(tag, cls, text) {
|
|
60
78
|
const node = document.createElement(tag);
|
|
@@ -78,6 +96,18 @@ function isStructured(v) {
|
|
|
78
96
|
return v !== null && typeof v === 'object';
|
|
79
97
|
}
|
|
80
98
|
|
|
99
|
+
/**
|
|
100
|
+
* 有没有「结构化正文」可铺 —— 容器,或者**装着 JSON 的字符串**。
|
|
101
|
+
*
|
|
102
|
+
* 后者是业务里的常态:`tool.output` 的 `content` 常常就是 `JSON.stringify(...)` 的结果
|
|
103
|
+
* (工具把结构序列化后塞回文本字段,MCP 的 text block 更是如此)。此前这种值是字符串 ⇒
|
|
104
|
+
* 走纯文本老路 ⇒ 展开后是一串 `{\"a\":1` 的反斜杠汤。判据与 `jsonInString` 完全同一套,
|
|
105
|
+
* 所以「挂不挂块」与「块里画什么」不会跑偏。
|
|
106
|
+
*/
|
|
107
|
+
function hasStructuredBody(v) {
|
|
108
|
+
return isStructured(v) || jsonInString(v) !== null;
|
|
109
|
+
}
|
|
110
|
+
|
|
81
111
|
/** 普通对象(排除数组与 null)—— 表格化与"容器"判定都只要这一种 */
|
|
82
112
|
function isPlainObject(v) {
|
|
83
113
|
return isStructured(v) && !Array.isArray(v);
|
|
@@ -119,6 +149,47 @@ function oneLine(v) {
|
|
|
119
149
|
return `<${kind} · ${sizeOf(v)} 项> ${clipped}`;
|
|
120
150
|
}
|
|
121
151
|
|
|
152
|
+
/**
|
|
153
|
+
* 「字符串里套着 JSON」:返回解析出来的容器,其余(普通文本、标量字符串、坏 JSON)一律 `null`。
|
|
154
|
+
*
|
|
155
|
+
* 判据是**首尾字符**而不是「parse 成功」,这一条是这段代码的全部要害:`"123"`、`"null"`、
|
|
156
|
+
* `"true"` 都能被 `JSON.parse` 吃下去,但把它们显示成数字/空值/布尔,等于把工具返回的
|
|
157
|
+
* 字符串改写成另一种类型 —— 调试面板的第一职责是如实。所以先看 trim 后的首尾一个字符,
|
|
158
|
+
* 不吻合就连 parse 都不试(普通文本占绝大多数,省掉无谓的反序列化)。
|
|
159
|
+
* 截断过的 JSON(尾部被切掉)在这里自然失败,回落成原样字符串。
|
|
160
|
+
*/
|
|
161
|
+
function jsonInString(v) {
|
|
162
|
+
if (typeof v !== 'string' || v.length < 2 || v.length > MAX_JSON_STR) return null;
|
|
163
|
+
const t = v.trim();
|
|
164
|
+
if (t.length < 2) return null;
|
|
165
|
+
const head = t[0];
|
|
166
|
+
const tail = t[t.length - 1];
|
|
167
|
+
if (!((head === '{' && tail === '}') || (head === '[' && tail === ']'))) return null;
|
|
168
|
+
try {
|
|
169
|
+
// 首尾判据已经保证了这里解析出来的必然是对象或数组(`{`…`}` / `[`…`]`),
|
|
170
|
+
// 所以不再重复判类型 —— 那会是一条永远走不到的防御分支(变异测试实测盖不住)。
|
|
171
|
+
return JSON.parse(t);
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 「这个字符串里装着 JSON」的标记。**必须显眼**:不标出来,读者会以为工具真的返回了对象。
|
|
178
|
+
* title 顺带交代两件容易误会的事 —— 原文还在(`.tr-io` 与块内原值),路径需先 parse 这一层。 */
|
|
179
|
+
function jsonBadge() {
|
|
180
|
+
const b = fvEl('span', 'tr-full-json', 'str→json');
|
|
181
|
+
b.title = '这个值是字符串,内容是 JSON(已按对象展示;内部路径需先 JSON.parse 这一层)';
|
|
182
|
+
return b;
|
|
183
|
+
}
|
|
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
|
+
|
|
122
193
|
/** 子键的 JSON 路径:对象用 `.key`,数组用 `[i]`。根是空串,所以 `[0]` 不会变成 `.[0]` */
|
|
123
194
|
function pathOf(parent, key, isArr) {
|
|
124
195
|
return isArr ? `${parent}[${key}]` : parent ? `${parent}.${key}` : key;
|
|
@@ -251,8 +322,9 @@ function startRow(depth, caret) {
|
|
|
251
322
|
* @param {any} v 值
|
|
252
323
|
* @param {number} depth 当前缩进层级(根为 0)
|
|
253
324
|
* @param {string} path 当前值的 JSON 路径(根为空串)
|
|
325
|
+
* @param {boolean} [allowParse] 还允许不允从字符串里解析 JSON(进了「解析出来的子树」就为 false)
|
|
254
326
|
*/
|
|
255
|
-
function renderInto(ctx, v, depth, path) {
|
|
327
|
+
function renderInto(ctx, v, depth, path, allowParse = true) {
|
|
256
328
|
const box = ctx.body;
|
|
257
329
|
|
|
258
330
|
if (!isStructured(v)) {
|
|
@@ -277,14 +349,19 @@ function renderInto(ctx, v, depth, path) {
|
|
|
277
349
|
const child = isArr ? v[Number(k)] : v[k];
|
|
278
350
|
const keyText = isArr ? `[${k}]` : `${k}:`;
|
|
279
351
|
const childPath = pathOf(path, k, isArr);
|
|
352
|
+
// 字符串里套着的 JSON(`content: "{\"rows\":[...]}"`)⇒ 按容器铺。`allowParse` 只在
|
|
353
|
+
// 「刚从一个字符串里解析出来」的那棵子树上为 false —— 一层套一层会放大成十万个节点。
|
|
354
|
+
const parsed = allowParse ? jsonInString(child) : null;
|
|
355
|
+
const val = parsed ?? child;
|
|
356
|
+
const fromJson = parsed !== null;
|
|
280
357
|
// 「能否折叠」只看三件事:是容器、非空、没到深度上限 —— 与下面几个分支同一套判据。
|
|
281
358
|
// 先算出来是为了让箭头槽位在起行时就有正确字形(叶子行留空格位,键名才会对齐)。
|
|
282
|
-
const foldable = isStructured(
|
|
359
|
+
const foldable = isStructured(val) && sizeOf(val) > 0 && depth < MAX_DEPTH;
|
|
283
360
|
const open = foldable && isOpen(ctx.fold, childPath, depth);
|
|
284
361
|
const row = startRow(depth, foldable ? (open ? '▾' : '▸') : '');
|
|
285
362
|
|
|
286
363
|
// 叶子:同一行里写完(缩进 + 键名 + 路径 + 值)
|
|
287
|
-
if (!isStructured(
|
|
364
|
+
if (!isStructured(val)) {
|
|
288
365
|
const p = primitive(child);
|
|
289
366
|
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
290
367
|
row.appendChild(pathBtn(childPath));
|
|
@@ -296,28 +373,31 @@ function renderInto(ctx, v, depth, path) {
|
|
|
296
373
|
// 深度用尽 ⇒ 整体压成一行(并标明它是个容器),避免缩进吃掉全部宽度
|
|
297
374
|
if (depth >= MAX_DEPTH) {
|
|
298
375
|
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
299
|
-
row.appendChild(
|
|
376
|
+
if (fromJson) row.appendChild(jsonBadge());
|
|
377
|
+
row.appendChild(fvEl('span', 'tr-full-null', ' ' + oneLine(val)));
|
|
300
378
|
box.appendChild(row);
|
|
301
379
|
continue;
|
|
302
380
|
}
|
|
303
381
|
|
|
304
|
-
// 空容器:留一对括号,别让它看起来像"展开失败"
|
|
305
|
-
if (sizeOf(
|
|
382
|
+
// 空容器:留一对括号,别让它看起来像"展开失败"(`"{}"` / `"[]"` 这类字符串也走这里)
|
|
383
|
+
if (sizeOf(val) === 0) {
|
|
306
384
|
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
307
|
-
row.appendChild(
|
|
385
|
+
if (fromJson) row.appendChild(jsonBadge());
|
|
386
|
+
row.appendChild(fvEl('span', 'tr-full-null', Array.isArray(val) ? ' []' : ' {}'));
|
|
308
387
|
box.appendChild(row);
|
|
309
388
|
continue;
|
|
310
389
|
}
|
|
311
390
|
|
|
312
391
|
row.appendChild(fvEl('span', 'tr-full-key', keyText));
|
|
313
392
|
row.appendChild(pathBtn(childPath));
|
|
393
|
+
if (fromJson) row.appendChild(jsonBadge());
|
|
314
394
|
if (!open) {
|
|
315
|
-
const kind = Array.isArray(
|
|
316
|
-
row.appendChild(fvEl('span', 'tr-full-hint', ` { ${kind} · ${sizeOf(
|
|
317
|
-
} else if (Array.isArray(
|
|
395
|
+
const kind = Array.isArray(val) ? 'array' : 'object';
|
|
396
|
+
row.appendChild(fvEl('span', 'tr-full-hint', ` { ${kind} · ${sizeOf(val)} 项 }`));
|
|
397
|
+
} else if (Array.isArray(val) && tableColsOf(val)) {
|
|
318
398
|
// 成表时行内不铺子项,只在键名后标一句规模 —— 否则 `▾ targets:` 后面直接跟着表格,
|
|
319
399
|
// 看不出这是个数组、也看不出表格是不是全部内容(是不是被截断了)
|
|
320
|
-
row.appendChild(fvEl('span', 'tr-full-hint', ` array · ${
|
|
400
|
+
row.appendChild(fvEl('span', 'tr-full-hint', ` array · ${val.length} 项`));
|
|
321
401
|
}
|
|
322
402
|
row.dataset.fold = childPath;
|
|
323
403
|
row.addEventListener('click', (e) => {
|
|
@@ -328,7 +408,7 @@ function renderInto(ctx, v, depth, path) {
|
|
|
328
408
|
});
|
|
329
409
|
box.appendChild(row);
|
|
330
410
|
|
|
331
|
-
if (open) renderInto(ctx,
|
|
411
|
+
if (open) renderInto(ctx, val, depth + 1, childPath, fromJson ? false : allowParse);
|
|
332
412
|
}
|
|
333
413
|
|
|
334
414
|
if (keys.length > show.length) {
|
|
@@ -341,33 +421,62 @@ function renderInto(ctx, v, depth, path) {
|
|
|
341
421
|
/**
|
|
342
422
|
* 建一个结构化正文块。
|
|
343
423
|
* @param {any} value `ev.full` 的**原值**(不是序列化后的字符串)
|
|
344
|
-
* @param {{fold?: Map<string, boolean
|
|
424
|
+
* @param {{fold?: Map<string, boolean>, body?: Element}} [opts]
|
|
345
425
|
* `fold` 是**调用方持有**的折叠状态(路径 → 是否展开)。省略时本块自建一份,
|
|
346
426
|
* 此时折叠状态活不过下一次「全量重建」—— 只对一次性渲染(测试、静态快照)够用。
|
|
427
|
+
* `body` 是**宿主已经渲染好的正文**(如 Markdown):给了就原样用,不再走缩进/折叠,
|
|
428
|
+
* 本件只负责头部(类型 / `md` 标记 / 复制)与挂载位置。
|
|
347
429
|
* @returns {Element} `.tr-full` 块;调用方负责 append 到展开行之后
|
|
348
430
|
*/
|
|
349
431
|
function createFullView(value, opts = {}) {
|
|
350
432
|
const fold = opts.fold instanceof Map ? opts.fold : new Map();
|
|
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);
|
|
441
|
+
const root = parsedRoot ?? value;
|
|
442
|
+
const fromJson = parsedRoot !== null;
|
|
351
443
|
|
|
352
444
|
const box = fvEl('div', 'tr-full');
|
|
353
|
-
box.dataset.kind = Array.isArray(
|
|
445
|
+
box.dataset.kind = injected ? 'md' : Array.isArray(root) ? 'array' : 'object';
|
|
354
446
|
|
|
355
447
|
const head = fvEl('div', 'tr-full-head');
|
|
356
|
-
|
|
448
|
+
const label = typeLabelOf(root);
|
|
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));
|
|
455
|
+
if (fromJson) head.appendChild(jsonBadge());
|
|
456
|
+
if (injected) head.appendChild(mdBadge());
|
|
357
457
|
const copy = fvEl('span', 'tr-full-copy', '复制');
|
|
358
458
|
copy.dataset.copy = '1';
|
|
359
|
-
|
|
459
|
+
// 字符串根复制的是**原文**(拿到手能直接喂 jq / 贴回上游);容器根复制的是缩进后的 JSON
|
|
460
|
+
copy.addEventListener('click', () =>
|
|
461
|
+
copyInto(copy, fromJson || injected ? String(value) : safeStringify(root), '已复制'),
|
|
462
|
+
);
|
|
360
463
|
head.appendChild(copy);
|
|
361
464
|
box.appendChild(head);
|
|
362
465
|
|
|
363
466
|
const body = fvEl('div', 'tr-full-body');
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
}
|
|
371
480
|
|
|
372
481
|
box.appendChild(body);
|
|
373
482
|
return box;
|
|
@@ -393,10 +502,16 @@ function createFullView(value, opts = {}) {
|
|
|
393
502
|
* 宿主需提供的 CSS 变量:--ice --faint --text(见 trace-view.css)。
|
|
394
503
|
*/
|
|
395
504
|
|
|
396
|
-
/*
|
|
397
|
-
* -
|
|
398
|
-
* - 对象/数组 → 追加一个 `.tr-full` 结构化块(折叠 / 成表 / 路径复制),`.tr-io` 由 CSS
|
|
399
|
-
*
|
|
505
|
+
/* 展开态的正文有三种形状(2026-11-11,2026-11-12 扩了两档):
|
|
506
|
+
* - 纯文本字符串 → 老路:`.tr-io` 里放原文,靠 `.tr-open` 换行(纯文本正文、官网模拟回放);
|
|
507
|
+
* - 对象/数组 → 追加一个 `.tr-full` 结构化块(折叠 / 成表 / 路径复制),`.tr-io` 由 CSS 隐藏;
|
|
508
|
+
* - **装着 JSON 的字符串**(`content: "{\"rows\":[...]}"`,工具把结构序列化后塞回文本字段)
|
|
509
|
+
* → 同上一档,块头写明「string · N 字符 → object · M 项」并打 `str→json` 标记;
|
|
510
|
+
* - **一整篇 Markdown 的字符串**(工具出参就是一份 SKILL.md / 报告)→ 由宿主注入的
|
|
511
|
+
* `opts.textBody(text)` 渲染成正文块,块头打 `md` 标记。注入是刻意的:本包是独立
|
|
512
|
+
* 发布单元,解析器只有一份(在宿主里),两边各带一份必然漂移。
|
|
513
|
+
* 判据与块内完全同源(`hasStructuredBody`),「挂不挂块」与「块里画什么」因此不会跑偏。
|
|
514
|
+
* 前两档并存是刻意的:纯文本那条路有 test/style.test.js 的两个不变量守着(最小宽度、caret 常显),
|
|
400
515
|
* 全换成本件会让那两个守卫失去落点。 */
|
|
401
516
|
|
|
402
517
|
|
|
@@ -464,6 +579,9 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
464
579
|
let price = opts.price ?? null;
|
|
465
580
|
const onUsage = opts.onUsage ?? null;
|
|
466
581
|
const usageEls = opts.usage ?? null;
|
|
582
|
+
/* 字符串正文的宿主渲染器(可选):`(text) => Element | null`。返回 null = 「不是那种
|
|
583
|
+
正文,保持原文」。本包不自带 Markdown 解析器 —— 见文件头注。 */
|
|
584
|
+
const textBody = typeof opts.textBody === 'function' ? opts.textBody : null;
|
|
467
585
|
|
|
468
586
|
let traceRoot = null;
|
|
469
587
|
const spanMap = new Map();
|
|
@@ -491,6 +609,17 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
491
609
|
renderTrace();
|
|
492
610
|
}
|
|
493
611
|
|
|
612
|
+
/** 宿主渲染器包一层:**只在展开时调用**(折叠态每条事件都会重画,挂在那儿等于白解析
|
|
613
|
+
* 一遍所有历史正文);宿主抛错也不许让整棵树白屏 —— 回落原文即可。 */
|
|
614
|
+
function textBodyOf(v) {
|
|
615
|
+
if (!textBody || typeof v !== 'string') return null;
|
|
616
|
+
try {
|
|
617
|
+
return textBody(v) || null;
|
|
618
|
+
} catch {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
494
623
|
function renderTrace() {
|
|
495
624
|
rootEl.innerHTML = '';
|
|
496
625
|
const rows = [];
|
|
@@ -523,10 +652,13 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
523
652
|
// 这一格整个不渲染,别把「无工具」显示成假工具名(.tr-name 是 flex:none,.tr-io 自然占满)
|
|
524
653
|
if (ev.tool) row.appendChild(el('span', 'tr-name', ev.tool));
|
|
525
654
|
// 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致。
|
|
526
|
-
// `full`
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
const
|
|
655
|
+
// `full` 是对象/数组、装着 JSON 的字符串、或宿主认得的正文(Markdown)时
|
|
656
|
+
// (2026-11-11 / 2026-11-12):`.tr-io` 仍放**原文**(结构化块挂在行后、由 CSS 隐藏本格),
|
|
657
|
+
// 所以「展开拿到的是原文而不是 62 字符摘要」这条不变量原样成立。
|
|
658
|
+
const structured = hasStructuredBody(ev.full);
|
|
659
|
+
const mdBody = open && !structured ? textBodyOf(ev.full) : null;
|
|
660
|
+
const blocked = structured || mdBody !== null;
|
|
661
|
+
const fullText = blocked ? rawArg(ev.full) : ev.full || '';
|
|
530
662
|
const io = el('span', 'tr-io', (open ? fullText : ev.text) || '');
|
|
531
663
|
// 展开后正文已在行里,再挂一个占满整行的原生 tooltip 只会挡视线
|
|
532
664
|
io.title = open ? '' : ev.text || '';
|
|
@@ -534,7 +666,7 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
534
666
|
if (ev.full) {
|
|
535
667
|
row.dataset.expandable = '1'; // 可展开标记:CSS 靠它出 caret / cursor,测试靠它找行
|
|
536
668
|
row.appendChild(el('span', 'tr-caret', open ? '▾' : '▸'));
|
|
537
|
-
if (open) row.className += ' tr-open' + (
|
|
669
|
+
if (open) row.className += ' tr-open' + (blocked ? ' tr-structured' : '');
|
|
538
670
|
row.addEventListener('click', () => {
|
|
539
671
|
if (!hasSelection()) toggleExpand(ev.key);
|
|
540
672
|
});
|
|
@@ -543,13 +675,15 @@ function createTraceView(rootEl, opts = {}) {
|
|
|
543
675
|
// 结构化正文是**兄弟块**而不是 `.tr-io` 的子节点:行是 flex + nowrap,
|
|
544
676
|
// 塞进去会把树的列对齐打乱;独立成块才能自己换行、自己缩进。
|
|
545
677
|
// 折叠状态按事件键取一份带进去(2026-11-11 晚):块每次重建都 new,状态不能放块里。
|
|
546
|
-
|
|
678
|
+
// `body` 是宿主渲染好的正文(Markdown),交给同一条挂载路径:块头、复制、`.tr-io`
|
|
679
|
+
// 隐藏这些行为必须一致,否则「哪种正文」会从**外观**上分叉成两套。
|
|
680
|
+
if (open && blocked) {
|
|
547
681
|
let fold = folds.get(ev.key);
|
|
548
682
|
if (!fold) {
|
|
549
683
|
fold = new Map();
|
|
550
684
|
folds.set(ev.key, fold);
|
|
551
685
|
}
|
|
552
|
-
rootEl.appendChild(createFullView(ev.full, { fold }));
|
|
686
|
+
rootEl.appendChild(createFullView(ev.full, { fold, body: mdBody }));
|
|
553
687
|
}
|
|
554
688
|
return;
|
|
555
689
|
}
|
|
@@ -1089,10 +1223,11 @@ function escapeHtml(s) {
|
|
|
1089
1223
|
|
|
1090
1224
|
|
|
1091
1225
|
|
|
1092
|
-
/* 结构化正文块(2026-11-11):`full`
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1226
|
+
/* 结构化正文块(2026-11-11):`full` 是容器时,渲染器会给展开行挂上这个块;
|
|
1227
|
+
* 2026-11-12 起「装着 JSON 的字符串」也走这条路(`hasStructuredBody` 是同一个判据,
|
|
1228
|
+
* 宿主自己判断要不要挂块时用得上)。单独导出是因为它是**纯函数式的 DOM 构造**
|
|
1229
|
+
* (入参 → 元素),宿主可以拿它渲染任意值 —— 而 bundle 的导出面必须与这里逐字一致
|
|
1230
|
+
* (`test/bundle.test.js`),所以内部件不进公共面、这两件必须进。 */
|
|
1096
1231
|
|
|
1097
1232
|
export {
|
|
1098
1233
|
CAP_ICO,
|
|
@@ -1102,6 +1237,7 @@ export {
|
|
|
1102
1237
|
fmtArg,
|
|
1103
1238
|
fmtMs,
|
|
1104
1239
|
fmtNum,
|
|
1240
|
+
hasStructuredBody,
|
|
1105
1241
|
playTrace,
|
|
1106
1242
|
rawArg,
|
|
1107
1243
|
renderSummary,
|
|
@@ -284,6 +284,29 @@
|
|
|
284
284
|
.tr-full-hint {
|
|
285
285
|
color: var(--faint);
|
|
286
286
|
}
|
|
287
|
+
/* 「这个字符串里装着 JSON」的标记。它不是装饰,是**事实的一部分**:没有它,读者会以为
|
|
288
|
+
工具真的返回了对象。所以常显、不等 hover,也不跟 .tr-full-hint 一样隐形 —— 但保持
|
|
289
|
+
低对比度,避免每行都在喊。 */
|
|
290
|
+
.tr-full-json {
|
|
291
|
+
color: var(--faint);
|
|
292
|
+
font-size: 10px;
|
|
293
|
+
line-height: 1.4;
|
|
294
|
+
border: 1px solid #2c2c32;
|
|
295
|
+
border-radius: 3px;
|
|
296
|
+
padding: 0 4px;
|
|
297
|
+
margin-right: 4px;
|
|
298
|
+
}
|
|
299
|
+
/* `md`:这段正文被 Markdown 渲染过(2026-11-12)。与 `str→json` 同一套外观 ——
|
|
300
|
+
两者都是「你看到的不是原样」的告示,样式分叉只会让人以为它们是不同性质的东西。 */
|
|
301
|
+
.tr-full-md {
|
|
302
|
+
color: var(--faint);
|
|
303
|
+
font-size: 10px;
|
|
304
|
+
line-height: 1.4;
|
|
305
|
+
border: 1px solid #2c2c32;
|
|
306
|
+
border-radius: 3px;
|
|
307
|
+
padding: 0 4px;
|
|
308
|
+
margin-right: 4px;
|
|
309
|
+
}
|
|
287
310
|
/* `⧉` 复制路径:hover 才露。与 .tr-caret 的区别是**它暴露的是动作、不是信息** ——
|
|
288
311
|
路径的内容(键名)始终写在行里,常显只会给每行多一个字形噪声。
|
|
289
312
|
没有剪贴板实现时照样显示、点了不谎报成功(见 full-view.js 的 copyInto)。 */
|
package/dist/inspector/view.js
CHANGED
|
@@ -17,12 +17,18 @@
|
|
|
17
17
|
* 宿主需提供的 CSS 变量:--ice --faint --text(见 trace-view.css)。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
/*
|
|
21
|
-
* -
|
|
22
|
-
* - 对象/数组 → 追加一个 `.tr-full` 结构化块(折叠 / 成表 / 路径复制),`.tr-io` 由 CSS
|
|
23
|
-
*
|
|
20
|
+
/* 展开态的正文有三种形状(2026-11-11,2026-11-12 扩了两档):
|
|
21
|
+
* - 纯文本字符串 → 老路:`.tr-io` 里放原文,靠 `.tr-open` 换行(纯文本正文、官网模拟回放);
|
|
22
|
+
* - 对象/数组 → 追加一个 `.tr-full` 结构化块(折叠 / 成表 / 路径复制),`.tr-io` 由 CSS 隐藏;
|
|
23
|
+
* - **装着 JSON 的字符串**(`content: "{\"rows\":[...]}"`,工具把结构序列化后塞回文本字段)
|
|
24
|
+
* → 同上一档,块头写明「string · N 字符 → object · M 项」并打 `str→json` 标记;
|
|
25
|
+
* - **一整篇 Markdown 的字符串**(工具出参就是一份 SKILL.md / 报告)→ 由宿主注入的
|
|
26
|
+
* `opts.textBody(text)` 渲染成正文块,块头打 `md` 标记。注入是刻意的:本包是独立
|
|
27
|
+
* 发布单元,解析器只有一份(在宿主里),两边各带一份必然漂移。
|
|
28
|
+
* 判据与块内完全同源(`hasStructuredBody`),「挂不挂块」与「块里画什么」因此不会跑偏。
|
|
29
|
+
* 前两档并存是刻意的:纯文本那条路有 test/style.test.js 的两个不变量守着(最小宽度、caret 常显),
|
|
24
30
|
* 全换成本件会让那两个守卫失去落点。 */
|
|
25
|
-
import { createFullView } from './full-view.js';
|
|
31
|
+
import { createFullView, hasStructuredBody } from './full-view.js';
|
|
26
32
|
|
|
27
33
|
export const CAP_ICO = { tool: '⚙', skill: '◆', prompt: '¶', subagent: '⊕' };
|
|
28
34
|
|
|
@@ -88,6 +94,9 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
88
94
|
let price = opts.price ?? null;
|
|
89
95
|
const onUsage = opts.onUsage ?? null;
|
|
90
96
|
const usageEls = opts.usage ?? null;
|
|
97
|
+
/* 字符串正文的宿主渲染器(可选):`(text) => Element | null`。返回 null = 「不是那种
|
|
98
|
+
正文,保持原文」。本包不自带 Markdown 解析器 —— 见文件头注。 */
|
|
99
|
+
const textBody = typeof opts.textBody === 'function' ? opts.textBody : null;
|
|
91
100
|
|
|
92
101
|
let traceRoot = null;
|
|
93
102
|
const spanMap = new Map();
|
|
@@ -115,6 +124,17 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
115
124
|
renderTrace();
|
|
116
125
|
}
|
|
117
126
|
|
|
127
|
+
/** 宿主渲染器包一层:**只在展开时调用**(折叠态每条事件都会重画,挂在那儿等于白解析
|
|
128
|
+
* 一遍所有历史正文);宿主抛错也不许让整棵树白屏 —— 回落原文即可。 */
|
|
129
|
+
function textBodyOf(v) {
|
|
130
|
+
if (!textBody || typeof v !== 'string') return null;
|
|
131
|
+
try {
|
|
132
|
+
return textBody(v) || null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
118
138
|
function renderTrace() {
|
|
119
139
|
rootEl.innerHTML = '';
|
|
120
140
|
const rows = [];
|
|
@@ -147,10 +167,13 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
147
167
|
// 这一格整个不渲染,别把「无工具」显示成假工具名(.tr-name 是 flex:none,.tr-io 自然占满)
|
|
148
168
|
if (ev.tool) row.appendChild(el('span', 'tr-name', ev.tool));
|
|
149
169
|
// 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致。
|
|
150
|
-
// `full`
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
const
|
|
170
|
+
// `full` 是对象/数组、装着 JSON 的字符串、或宿主认得的正文(Markdown)时
|
|
171
|
+
// (2026-11-11 / 2026-11-12):`.tr-io` 仍放**原文**(结构化块挂在行后、由 CSS 隐藏本格),
|
|
172
|
+
// 所以「展开拿到的是原文而不是 62 字符摘要」这条不变量原样成立。
|
|
173
|
+
const structured = hasStructuredBody(ev.full);
|
|
174
|
+
const mdBody = open && !structured ? textBodyOf(ev.full) : null;
|
|
175
|
+
const blocked = structured || mdBody !== null;
|
|
176
|
+
const fullText = blocked ? rawArg(ev.full) : ev.full || '';
|
|
154
177
|
const io = el('span', 'tr-io', (open ? fullText : ev.text) || '');
|
|
155
178
|
// 展开后正文已在行里,再挂一个占满整行的原生 tooltip 只会挡视线
|
|
156
179
|
io.title = open ? '' : ev.text || '';
|
|
@@ -158,7 +181,7 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
158
181
|
if (ev.full) {
|
|
159
182
|
row.dataset.expandable = '1'; // 可展开标记:CSS 靠它出 caret / cursor,测试靠它找行
|
|
160
183
|
row.appendChild(el('span', 'tr-caret', open ? '▾' : '▸'));
|
|
161
|
-
if (open) row.className += ' tr-open' + (
|
|
184
|
+
if (open) row.className += ' tr-open' + (blocked ? ' tr-structured' : '');
|
|
162
185
|
row.addEventListener('click', () => {
|
|
163
186
|
if (!hasSelection()) toggleExpand(ev.key);
|
|
164
187
|
});
|
|
@@ -167,13 +190,15 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
167
190
|
// 结构化正文是**兄弟块**而不是 `.tr-io` 的子节点:行是 flex + nowrap,
|
|
168
191
|
// 塞进去会把树的列对齐打乱;独立成块才能自己换行、自己缩进。
|
|
169
192
|
// 折叠状态按事件键取一份带进去(2026-11-11 晚):块每次重建都 new,状态不能放块里。
|
|
170
|
-
|
|
193
|
+
// `body` 是宿主渲染好的正文(Markdown),交给同一条挂载路径:块头、复制、`.tr-io`
|
|
194
|
+
// 隐藏这些行为必须一致,否则「哪种正文」会从**外观**上分叉成两套。
|
|
195
|
+
if (open && blocked) {
|
|
171
196
|
let fold = folds.get(ev.key);
|
|
172
197
|
if (!fold) {
|
|
173
198
|
fold = new Map();
|
|
174
199
|
folds.set(ev.key, fold);
|
|
175
200
|
}
|
|
176
|
-
rootEl.appendChild(createFullView(ev.full, { fold }));
|
|
201
|
+
rootEl.appendChild(createFullView(ev.full, { fold, body: mdBody }));
|
|
177
202
|
}
|
|
178
203
|
return;
|
|
179
204
|
}
|
package/dist/inspector-page.html
CHANGED
|
@@ -180,6 +180,20 @@
|
|
|
180
180
|
.md blockquote { margin: 0 0 6px; padding-left: 9px; border-left: 2px solid var(--border); color: var(--muted); }
|
|
181
181
|
.md a { color: var(--ice); }
|
|
182
182
|
.md hr { border: 0; border-top: 1px solid var(--border); margin: 8px 0; }
|
|
183
|
+
/* 表格(2026-11-12):右栏只有 ~400px,内边距压到 3px 6px,列多时还能读;
|
|
184
|
+
对齐用类名,不用 inline style(与「innerHTML 只许一处」同类纪律)。 */
|
|
185
|
+
.md-table { border-collapse: collapse; margin: 0 0 6px; font-size: 11.5px; }
|
|
186
|
+
.md-table:last-child { margin-bottom: 0; }
|
|
187
|
+
.md-table th, .md-table td { border: 1px solid var(--border); padding: 3px 6px; }
|
|
188
|
+
.md-table th { background: var(--card); color: var(--text); font-weight: 600; }
|
|
189
|
+
.md-table .md-right { text-align: right; }
|
|
190
|
+
.md-table .md-center { text-align: center; }
|
|
191
|
+
.md-table .md-left { text-align: left; }
|
|
192
|
+
/* trace 树里的 Markdown 正文(2026-11-12):树本身是 11px,正文块跟着收紧一档;
|
|
193
|
+
字体不换 —— 一块正文突然换成无衬线,会和相邻的行错开视觉基线。 */
|
|
194
|
+
.md-trace { font-size: 11.5px; line-height: 1.55; }
|
|
195
|
+
.md-trace .md-h { font-size: 12px; margin: 8px 0 3px; }
|
|
196
|
+
.md-trace .md-table { font-size: 11px; }
|
|
183
197
|
/* 正文用无衬线正文栈(模型输出是散文,等宽栈读它更累);码块与行内码仍是等宽。
|
|
184
198
|
挂在 `.md-wrap`(渲染器的包装节点)而不是 `#reply .md, .turn .md`:单一类选择器
|
|
185
199
|
既不用管特异性排序,也让「哪块是模型正文」只由一处决定。 */
|
|
@@ -352,7 +366,7 @@
|
|
|
352
366
|
|
|
353
367
|
<script type="module">
|
|
354
368
|
import { createTraceView, playTrace, summarizeTrace, renderSummary } from './index.js';
|
|
355
|
-
import { parseMarkdown } from './markdown.js';
|
|
369
|
+
import { parseMarkdown, looksLikeMarkdown } from './markdown.js';
|
|
356
370
|
import {
|
|
357
371
|
HISTORY_DRAFT,
|
|
358
372
|
applyTraceEvent,
|
|
@@ -423,12 +437,17 @@
|
|
|
423
437
|
const view = createTraceView(traceEl, {
|
|
424
438
|
// 无公开单价 → 不估算成本;只展示 token
|
|
425
439
|
usage: { in: $('u-in'), out: $('u-out') },
|
|
440
|
+
// 展开的工具出参若是 Markdown(SKILL.md / 报告),渲染成正文而不是源文;
|
|
441
|
+
// 判据与解析器只有一份(markdown.ts),见 traceTextBody 的口径注
|
|
442
|
+
textBody: traceTextBody,
|
|
426
443
|
});
|
|
427
444
|
|
|
428
445
|
// 并排 A/B 的**第二棵**树。刻意不共用 `view`:`createTraceView` 绑一个容器 +
|
|
429
446
|
// 一个 usage 出口,两棵树塞进同一容器会互相清空(trace-view 的 `playTrace` 是
|
|
430
447
|
// 整容器替换式的)。共用 `u-in`/`u-out` 也无意义 —— 那两个读数属于「正在看的那条」。
|
|
431
|
-
|
|
448
|
+
// `textBody` 与主树用**同一个**:同一份 trace 在两栏里必须长成同一个样子,
|
|
449
|
+
// 否则「并排看差异」会先看见渲染差异。
|
|
450
|
+
const view2 = createTraceView(trace2El, { textBody: traceTextBody });
|
|
432
451
|
|
|
433
452
|
// ---------- 并排 A/B(档 B) ----------
|
|
434
453
|
|
|
@@ -871,6 +890,37 @@
|
|
|
871
890
|
list.appendChild(li);
|
|
872
891
|
}
|
|
873
892
|
el.appendChild(list);
|
|
893
|
+
} else if (b.type === 'table') {
|
|
894
|
+
/* 表格(2026-11-12):错误汇总 / 逐项统计天然成表,一律渲染。
|
|
895
|
+
对齐走类名(`md-left` / `md-right` / `md-center`),不写 inline style ——
|
|
896
|
+
全页对 style 属性的写入为 0,这条和「innerHTML 只许一处」同一类纪律。 */
|
|
897
|
+
const table = document.createElement('table');
|
|
898
|
+
table.className = 'md-table';
|
|
899
|
+
const thead = document.createElement('thead');
|
|
900
|
+
const htr = document.createElement('tr');
|
|
901
|
+
b.header.forEach((cell, ci) => {
|
|
902
|
+
const th = document.createElement('th');
|
|
903
|
+
const al = b.align[ci];
|
|
904
|
+
if (al) th.className = 'md-' + al;
|
|
905
|
+
appendInline(th, cell);
|
|
906
|
+
htr.appendChild(th);
|
|
907
|
+
});
|
|
908
|
+
thead.appendChild(htr);
|
|
909
|
+
table.appendChild(thead);
|
|
910
|
+
const tbody = document.createElement('tbody');
|
|
911
|
+
for (const row of b.rows) {
|
|
912
|
+
const tr = document.createElement('tr');
|
|
913
|
+
row.forEach((cell, ci) => {
|
|
914
|
+
const td = document.createElement('td');
|
|
915
|
+
const al = b.align[ci];
|
|
916
|
+
if (al) td.className = 'md-' + al;
|
|
917
|
+
appendInline(td, cell);
|
|
918
|
+
tr.appendChild(td);
|
|
919
|
+
});
|
|
920
|
+
tbody.appendChild(tr);
|
|
921
|
+
}
|
|
922
|
+
table.appendChild(tbody);
|
|
923
|
+
el.appendChild(table);
|
|
874
924
|
} else if (b.type === 'quote') {
|
|
875
925
|
const q = document.createElement('blockquote');
|
|
876
926
|
appendInline(q, b.inline);
|
|
@@ -885,6 +935,25 @@
|
|
|
885
935
|
}
|
|
886
936
|
}
|
|
887
937
|
|
|
938
|
+
/**
|
|
939
|
+
* trace 树里「一整篇 Markdown」的字符串正文 —— 挂给 trace-view 的 `textBody` 钩子。
|
|
940
|
+
*
|
|
941
|
+
* 工具出参的另一种常见形态就是一份 SKILL.md / 错误汇总报告:类型是**字符串** ⇒
|
|
942
|
+
* 之前只能走 `.tr-io` 的原文,展开后是 `<!-- 迁移自 … -->`、`# 标题`、`|---|---|`
|
|
943
|
+
* 的源文。这里把它渲染成正文(`<!-- -->` 已在解析层丢弃)。
|
|
944
|
+
*
|
|
945
|
+
* 返回 `null` 表示「这不是 Markdown,保持原文」。判据与解析器都只有一份,在
|
|
946
|
+
* `markdown.ts`:trace-view 是**独立发布包**,不能反向依赖 CLI,所以走**注入**
|
|
947
|
+
* 而不是让它自己再带一份解析器 —— 两份必然漂移。
|
|
948
|
+
*/
|
|
949
|
+
function traceTextBody(text) {
|
|
950
|
+
if (!looksLikeMarkdown(text)) return null;
|
|
951
|
+
const box = document.createElement('div');
|
|
952
|
+
box.className = 'md md-trace';
|
|
953
|
+
renderMarkdownInto(box, text);
|
|
954
|
+
return box;
|
|
955
|
+
}
|
|
956
|
+
|
|
888
957
|
/**
|
|
889
958
|
* 一段**模型正文**(最终回复 / 助手轮)→ 填进 `wrap`。
|
|
890
959
|
*
|
package/dist/markdown.js
CHANGED
|
@@ -15,15 +15,22 @@
|
|
|
15
15
|
*
|
|
16
16
|
* 支持的子集(刻意不做的都在「不做」里):
|
|
17
17
|
* - 块:`#` 标题(1–6 级)、围栏码块(``` / ~~~,带语言标注)、无序/有序列表(**一层**)、
|
|
18
|
-
* 引用(合并成一段正文)、`---`
|
|
18
|
+
* 引用(合并成一段正文)、`---` 分隔线、**GFM 表格**(表头 / 分隔行 / 对齐 / `\|` 转义)、段落
|
|
19
19
|
* - 行内:行内码、`**粗**`、`*斜*`、`[文字](链接)`、`<https://…>` 自动链接
|
|
20
|
+
* - **HTML 注释被丢弃**(`<!-- … -->`,可跨行):注释不是正文。真实素材里最常见的就是
|
|
21
|
+
* 「迁移自 …」「TODO: 这条规则已废弃」这类作者旁注,让它们以字面文本占掉开头三四行,
|
|
22
|
+
* 比不渲染更糟。原文一个字节都不改(调用方手里仍是原串)⇒ 「隐藏」只发生在**渲染视图**里。
|
|
20
23
|
*
|
|
21
24
|
* 不做(都是刻意的,不是漏):
|
|
22
|
-
* - **不做 HTML
|
|
23
|
-
* -
|
|
25
|
+
* - **不做 HTML 透传**(安全前提,见上)—— 注释是唯一的例外,且是**丢掉**而不是渲染
|
|
26
|
+
* - **不做任务列表 / 嵌套列表 / 脚注 / 数学** —— 面板是调试视图,不是文档站
|
|
24
27
|
* - 不做 setext 标题(`===` 下划线式)—— 与分隔线歧义大、收益小
|
|
25
28
|
* - 不做图片(`![]()`)—— 面板**不去外网取图**:一次 run 的正文不该触发网络请求
|
|
26
29
|
* - 不做**词内** `_` 强调(`read_file_tool` 原样显示)—— 与 CommonMark 同口径,见 `parseInline`
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ 表格是 2026-11-12 补的(原先明写「不做表格」)。**那个判断是错的**:agent 的产出里
|
|
32
|
+
* 「错误汇总 / 逐项统计 / 参数对照」天然就是表,砍掉表格等于把最有信息量的几种结果打回
|
|
33
|
+
* 一行行 `| a | b |` 加上 `|---|---|` 的源文 —— 面板的读者被迫在脑子里做列对齐。
|
|
27
34
|
*/
|
|
28
35
|
/**
|
|
29
36
|
* 链接协议白名单。**这是安全边界,不是风格选择**:
|
|
@@ -43,6 +50,14 @@ const QUOTE = /^\s{0,3}>\s?(.*)$/;
|
|
|
43
50
|
const UL = /^(\s*)([-*+])\s+(.*)$/;
|
|
44
51
|
const OL = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
|
|
45
52
|
const BLANK = /^\s*$/;
|
|
53
|
+
/**
|
|
54
|
+
* 表格的分隔行:`| --- | :--: |` / `--- | ---` 两种写法都收。
|
|
55
|
+
* 至少**两列**(`(\|…)+` 强制的)—— 单列「表格」在面板里就是一段带竖线的文本,
|
|
56
|
+
* 成表只会白占一行表头,还容易被 `a | b` 这种正文误判。
|
|
57
|
+
*/
|
|
58
|
+
const TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
|
|
59
|
+
/** 「这个字符串值不值得渲染」的长度上限(见 `looksLikeMarkdown`)—— 几百 KB 逐行建 DOM 只会卡住面板 */
|
|
60
|
+
const MAX_RENDER_CHARS = 100_000;
|
|
46
61
|
/** 「单词字符」:`_` 的词内排除用它判边界(与 CommonMark 的 intraword 规则同口径) */
|
|
47
62
|
const WORD_CHAR = /[A-Za-z0-9_]/;
|
|
48
63
|
/** 下标可能越界(`text[-1]` / `text[len]` 都是 `undefined`)⇒ 越界一律当「不是单词字符」 */
|
|
@@ -52,15 +67,18 @@ function isWordChar(ch) {
|
|
|
52
67
|
/**
|
|
53
68
|
* 把一段 Markdown 文本解析成块序列。
|
|
54
69
|
*
|
|
55
|
-
*
|
|
70
|
+
* 四条与「真 Markdown」不同但**刻意**的口径:
|
|
56
71
|
* - 缩进 ≥2 空格的续行归上一条列表项(列表只解析**一层**:更深一层当正文,不递归建树);
|
|
57
72
|
* - 未闭合的围栏码块吃掉到文末(**不静默丢内容** —— 模型被截断时最常见的就是这个形态);
|
|
58
|
-
* -
|
|
73
|
+
* - 引用(`>`)合并成**一段正文**(面板里引用几乎总是模型在引日志,逐行建块只会更碎);
|
|
74
|
+
* - 表格按**表头**宽度对齐:缺的列补空、多出来的列丢掉(与 GFM 同口径)。表格之后紧接的
|
|
75
|
+
* 非 `|` 行立刻收尾 —— 表格没有围栏,边界只能靠「下一行还像不像表格」来判断。
|
|
59
76
|
*/
|
|
60
77
|
export function parseMarkdown(text) {
|
|
61
78
|
const src = String(text ?? '').replace(/\r\n?/g, '\n');
|
|
62
79
|
const lines = src.split('\n');
|
|
63
80
|
const blocks = [];
|
|
81
|
+
const comment = { open: false };
|
|
64
82
|
let i = 0;
|
|
65
83
|
let para = [];
|
|
66
84
|
const flushPara = () => {
|
|
@@ -72,7 +90,9 @@ export function parseMarkdown(text) {
|
|
|
72
90
|
blocks.push({ type: 'p', inline: parseInline(joined, 0) });
|
|
73
91
|
};
|
|
74
92
|
while (i < lines.length) {
|
|
75
|
-
|
|
93
|
+
// 注释先剥掉再看这一行是什么(围栏码块的内容由下面的内层循环整段吞掉,不会走到这里 ⇒
|
|
94
|
+
// 码块里的 `<!-- … -->` 原样保留,这正是「代码是字面量」的意思)
|
|
95
|
+
const line = stripComments(lines[i], comment);
|
|
76
96
|
// —— 围栏码块 ——
|
|
77
97
|
const fence = FENCE.exec(line);
|
|
78
98
|
if (fence) {
|
|
@@ -159,6 +179,34 @@ export function parseMarkdown(text) {
|
|
|
159
179
|
blocks.push({ type: 'list', ordered, items });
|
|
160
180
|
continue;
|
|
161
181
|
}
|
|
182
|
+
// —— 表格(GFM):表头行 + 分隔行**同时**成立才算 ——
|
|
183
|
+
// 两条都要,是这条判据的全部要害:只要「行里有 `|`」就成表,`a | b` 这种正文会被
|
|
184
|
+
// 吞成一行表头;只要「下一行像分隔行」就成表,`---` 分隔线下面那段会被吃掉。
|
|
185
|
+
if (isTableStart(line, lines[i + 1])) {
|
|
186
|
+
flushPara();
|
|
187
|
+
const header = splitRow(line).map((c) => parseInline(c, 0));
|
|
188
|
+
const align = splitRow(stripComments(lines[i + 1], { open: comment.open })).map(alignOf);
|
|
189
|
+
const rows = [];
|
|
190
|
+
i += 2;
|
|
191
|
+
while (i < lines.length) {
|
|
192
|
+
// 表格没有围栏:边界只能靠「下一行还像不像表格」。空白、另一个块的起始、
|
|
193
|
+
// 不含 `|` 的行,三者任一条都收尾(否则后面的正文会被当成格子吃掉)。
|
|
194
|
+
const raw = lines[i];
|
|
195
|
+
if (!raw.includes('|') || BLANK.test(raw))
|
|
196
|
+
break;
|
|
197
|
+
if (FENCE.test(raw) || HR.test(raw) || HEADING.test(raw) || QUOTE.test(raw))
|
|
198
|
+
break;
|
|
199
|
+
if (UL.test(raw) || olAt(raw))
|
|
200
|
+
break;
|
|
201
|
+
const cells = splitRow(raw).slice(0, header.length);
|
|
202
|
+
while (cells.length < header.length)
|
|
203
|
+
cells.push('');
|
|
204
|
+
rows.push(cells.map((c) => parseInline(c, 0)));
|
|
205
|
+
i++;
|
|
206
|
+
}
|
|
207
|
+
blocks.push({ type: 'table', align, header, rows });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
162
210
|
// —— 其余:段落(连续非空行合并,行内换行保留) ——
|
|
163
211
|
para.push(line.trim());
|
|
164
212
|
i++;
|
|
@@ -166,6 +214,122 @@ export function parseMarkdown(text) {
|
|
|
166
214
|
flushPara();
|
|
167
215
|
return blocks;
|
|
168
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* 剥掉 HTML 注释(可跨行)。`state.open` 跨行携带 —— 块级解析是逐行扫的,
|
|
219
|
+
* 没有这个开关,一段被空行隔开的三行注释会被当成三段正文(每段都带着半截 `<!--`)。
|
|
220
|
+
*
|
|
221
|
+
* 只在**非码块**的行上调用。码块内容由围栏那段内层循环整段吞掉,走不到这里 ——
|
|
222
|
+
* 所以「代码里的 `<!-- -->` 原样保留」不是靠额外判断,是靠调用点。
|
|
223
|
+
*/
|
|
224
|
+
function stripComments(line, state) {
|
|
225
|
+
let s = line;
|
|
226
|
+
for (;;) {
|
|
227
|
+
if (state.open) {
|
|
228
|
+
const end = s.indexOf('-->');
|
|
229
|
+
if (end < 0)
|
|
230
|
+
return '';
|
|
231
|
+
s = s.slice(end + 3);
|
|
232
|
+
state.open = false;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const start = s.indexOf('<!--');
|
|
236
|
+
if (start < 0)
|
|
237
|
+
return s;
|
|
238
|
+
const end = s.indexOf('-->', start + 4);
|
|
239
|
+
if (end < 0) {
|
|
240
|
+
state.open = true;
|
|
241
|
+
return s.slice(0, start);
|
|
242
|
+
}
|
|
243
|
+
s = s.slice(0, start) + s.slice(end + 3);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* 一行表格 → 格子文本(已 trim,`\|` 解成 `|`)。
|
|
248
|
+
* 首尾的定界竖线去掉;**但**以 `\|` 结尾时不能去 —— 那是最后一个格子里的字面竖线。
|
|
249
|
+
*/
|
|
250
|
+
function splitRow(line) {
|
|
251
|
+
let s = line.trim();
|
|
252
|
+
if (s.startsWith('|'))
|
|
253
|
+
s = s.slice(1);
|
|
254
|
+
if (s.endsWith('|') && !s.endsWith('\\|'))
|
|
255
|
+
s = s.slice(0, -1);
|
|
256
|
+
const cells = [];
|
|
257
|
+
let buf = '';
|
|
258
|
+
for (let i = 0; i < s.length; i++) {
|
|
259
|
+
const c = s[i];
|
|
260
|
+
if (c === '\\' && s[i + 1] === '|') {
|
|
261
|
+
buf += '|';
|
|
262
|
+
i++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (c === '|') {
|
|
266
|
+
cells.push(buf.trim());
|
|
267
|
+
buf = '';
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
buf += c;
|
|
271
|
+
}
|
|
272
|
+
cells.push(buf.trim());
|
|
273
|
+
return cells;
|
|
274
|
+
}
|
|
275
|
+
/** 分隔行的单个格子 → 对齐方式 */
|
|
276
|
+
function alignOf(cell) {
|
|
277
|
+
const t = cell.trim();
|
|
278
|
+
const left = t.startsWith(':');
|
|
279
|
+
const right = t.endsWith(':');
|
|
280
|
+
if (left && right)
|
|
281
|
+
return 'center';
|
|
282
|
+
if (right)
|
|
283
|
+
return 'right';
|
|
284
|
+
if (left)
|
|
285
|
+
return 'left';
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
/** 这一行是不是表格的开头:表头含 `|`、下一行是分隔行、且两行列数**相等** */
|
|
289
|
+
function isTableStart(head, sep) {
|
|
290
|
+
if (sep === undefined || !head.includes('|') || !TABLE_SEP.test(sep))
|
|
291
|
+
return false;
|
|
292
|
+
return splitRow(head).length === splitRow(sep).length;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 这段正文「值不值得按 Markdown 渲染」—— 给**宿主**用(trace 树里展开的工具出参)。
|
|
296
|
+
*
|
|
297
|
+
* 判据只认**块级**记号,且要求**至少两分**(围栏码块与表格各算两分,其余各一分):
|
|
298
|
+
* 标题 + 列表 = 2 分 ✅;只有一堆 `#` 注释 ❌;只有一处围栏码块 ✅。
|
|
299
|
+
*
|
|
300
|
+
* 为什么这么严:判错的代价不是「少渲染一点」,而是**把一段本来好读的文本毁掉** ——
|
|
301
|
+
* 日志行被并成段落、原有缩进被吃掉、行首的 `#` 与 `|` 凭空消失。所以宁可漏(保持原文),
|
|
302
|
+
* 不可错。
|
|
303
|
+
*
|
|
304
|
+
* 刻意**不**看行内记号(`**粗**` / `[x](y)`):`**kwargs` / `*args` 是 Python 源码里最常见
|
|
305
|
+
* 的东西,按行内记号判会把整份源码判成 Markdown —— 而那正是工具出参的另一种常见形态。
|
|
306
|
+
*
|
|
307
|
+
* 超过 `MAX_RENDER_CHARS` 一律不渲染:几百 KB 的正文逐行建 DOM 只会把面板卡住,
|
|
308
|
+
* 而这种长度的东西本来就该用「复制」拿走。
|
|
309
|
+
*/
|
|
310
|
+
export function looksLikeMarkdown(text) {
|
|
311
|
+
if (typeof text !== 'string' || text.length > MAX_RENDER_CHARS)
|
|
312
|
+
return false;
|
|
313
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
314
|
+
const kinds = new Set();
|
|
315
|
+
for (let i = 0; i < lines.length; i++) {
|
|
316
|
+
const line = lines[i];
|
|
317
|
+
if (FENCE.test(line))
|
|
318
|
+
kinds.add('fence');
|
|
319
|
+
else if (isTableStart(line, lines[i + 1]))
|
|
320
|
+
kinds.add('table');
|
|
321
|
+
else if (HEADING.test(line))
|
|
322
|
+
kinds.add('h');
|
|
323
|
+
else if (QUOTE.test(line))
|
|
324
|
+
kinds.add('q');
|
|
325
|
+
else if (UL.test(line) || olAt(line))
|
|
326
|
+
kinds.add('list');
|
|
327
|
+
}
|
|
328
|
+
let score = 0;
|
|
329
|
+
for (const k of kinds)
|
|
330
|
+
score += k === 'fence' || k === 'table' ? 2 : 1;
|
|
331
|
+
return score >= 2;
|
|
332
|
+
}
|
|
169
333
|
/** 有序列表标记(`1.` / `1)`),返回与 UL 同形的三组:缩进 / 标记 / 正文 */
|
|
170
334
|
function olAt(line) {
|
|
171
335
|
const m = OL.exec(line);
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@migor/agentia": "^0.10.
|
|
14
|
+
"@migor/agentia": "^0.10.9"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@migor/cli": "^0.10.
|
|
17
|
+
"@migor/cli": "^0.10.9",
|
|
18
18
|
"tsx": "^4.19.0",
|
|
19
19
|
"typescript": "^7.0.2",
|
|
20
20
|
"@types/node": "^22.0.0"
|
package/package.json
CHANGED