@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 CHANGED
@@ -5,6 +5,118 @@
5
5
  (0.x 阶段:minor 可含破坏性变更,每个破坏性变更都在对应版本的「迁移」小节里写明)。
6
6
  决策的完整证据链在 `docs/spec.md` §10(带时间线的决策日志)。
7
7
 
8
+ ## [0.10.10] - 2026-11-12
9
+
10
+ ### 变更
11
+
12
+ 调试面板从「**看得见一棵树**」推进到「**看得到这一趟 run 的账、找得到那一条、带得走**」。
13
+ 三批改动(#304 布局 / #305 说真话 / #306 补账与检索),无破坏性变更、无迁移动作。
14
+ 两包同步 0.10.9 → 0.10.10;`src/` 一行未动(`git diff v0.10.9..HEAD -- src/` 为空),
15
+ 框架包此次只有版本常量变化,实际改动全在 CLI 面板与 trace-view 渲染件。
16
+ 评审依据:`docs/reviews/2026-11-12/inspector-board-gap.md`。
17
+
18
+ - **#304 · 右栏三块都能折叠**:调用树 / 能力排行 / 回复原先高度写死(树 `flex: 1`、下面两块
19
+ 各 `38vh`),三块同开时谁都不够读;现在标题栏可点,`data-open` 与 `aria-expanded` 由
20
+ `applyPane` **一处**写入,收起后剩下的块按份分同一个剩余空间(树 1、回复 1.5、排行按内容
21
+ 上限 45%)。收起不等于失明:标题栏仍留规模读数。折叠态是布局偏好,进 localStorage。
22
+ ⚠️ 第一版踩过一处**状态全对、画面全错**:`#pane-trace > .pane-body` 的 `display: flex`
23
+ 挂在 id 选择器上,特异性压过折叠规则 ⇒ 折叠后树照样铺满;无头 Chrome 截图才看得见。
24
+ - **#305 · 修掉五处在说谎的地方**(只治这五处,不新增视图):
25
+
26
+ | 症状 | 根因 |
27
+ |---|---|
28
+ | 回复切走 / 刷新后就消失 | `finalText` 只接在 `run-done` 那一帧 SSE 上,trace 上根本没有它 |
29
+ | 失败 / 中止轮进不了对话视图 | `mergeConversation` 读 `r.prompt`,生产形状是 `note.prompt`;**单测喂的正是那个不存在的形状** |
30
+ | 根 span 上的事件整类丢失 | `reset()` 把根登记成 `'root'`,事件按真 `spanId` 投递 ⇒ 静默 return |
31
+ | 每条 run 都叫 `agent.run` | dev 环不传 `runName`,而 `note.prompt` 一直有货、一直没被用 |
32
+ | 14 种收尾原因只呈现 3 种(`suspended` 显示成「正常跑完」) | 只分辨中止 / 失败 / 正常,而挂起的 trace 状态是 **ok** |
33
+
34
+ 改法:`RunNote` 落 `finalText` 且 `/api/runs/:id` 带顶层 `note`(空回复**不写这个键** ——
35
+ 面板要能区分「本来没回复」与「回复丢了」);`reset(rootName, rootId)` 收下框架真 `spanId`;
36
+ 列表首行给 prompt;`stopReasonClass` 四类终态走「**白名单 + 未知落被拦住**」,框架新增成员时
37
+ 宁可显示成被拦住并露出原文,也不谎报正常收尾。
38
+ - **#306 · 先修地基,再补八件事**:`renderTrace()` 此前「每个动作 = 一次全树重建」,
39
+ 600 span 实测 **1801 次**重建(在飞时每帧还再来一次);拆成调度层 + `drawTree`,
40
+ `playTrace` 把 reset + 回放 + finish **整块**包进 `view.batch()` ⇒ 50 span 只重建 1 次。
41
+ 在此基础上补:run 级总账(轮次 / 工具 / 失败 / in-out / cache / 成本带预算上限 / 模型)、
42
+ span attributes 可见**含根**(`model` / `system.version` / `tools.names` 只写在根上)、
43
+ 时间轴(按 run 总时长归一化)、树内检索(只高亮跳转、不改结构)、列表检索(关键词 +
44
+ 只看异常,异常**含挂起**)、深链 `?run=`(`replaceState` 不堆历史)、重跑(**回填四个输入,
45
+ 不自动跑** —— 自动跑会在误触时花钱)、导出(复制这条 trace 的 JSONL,去掉 CLI 侧 `note`)。
46
+ - **三条「不给假读数」的口径**:① 总账缺字段的那一项**整项不出现**,不显示 `0%` / `$0.0000`
47
+ ——「成本是 0」与「没有单价表」是两件事;② cache 分母**含缓存写入**,否则刚写了一大块缓存
48
+ 的那次 run 会显示成 100% 命中,正是最容易误读的时刻;③ 时间轴**缺时间戳就整棵不画** ——
49
+ 手写 trace / 官网模拟回放画一条人人占满的条,比不画更误导。
50
+ - **顺手修的实拍发现**:三块同开时调用树只分到 134px ≈ 三行(夹具 23 行只看得见 3 行),
51
+ `flex-grow: 2` ⇒ 191px。取 2 不是更极端:回复仍要比它大,结论优先。
52
+
53
+ ### 新增:面板从「看树」到「看账」的三批收口
54
+
55
+ - 决策见 `docs/spec.md` §10 2026-11-12 ref-panel-panes、2026-11-12 ref-inspector-truth、
56
+ 2026-11-13 ref-inspector-board;守卫登记见 `docs/guards.md` §1。
57
+ - **新增守卫**:CLI `structure.test.mjs` 的 **W6**(折叠态只许在 `applyPane` 里写,
58
+ `data-open` 与 `aria-expanded` 必须一起改);`stop-reason.test.mjs` **跨包口径**
59
+ (框架每加一个 `AgentStopReason`,面板都得给出中文说法;只按行首 `|` 摘成员,
60
+ 注释里散落的例子不算);trace-view `render-batch.test.js`(批量 / 属性含根 / 时间轴 /
61
+ 树内检索);`panel-logic.test.mjs` + `inspector.test.mjs`(总账 / 检索 / 深链 / 回填接线)。
62
+ - **验证**:CLI **257 → 278**、trace-view **96 → 113**(两侧读数都在本机各跑一遍核过)。
63
+ 三批共 25 条变异各自咬住;#305 / #306 另用无头 Chrome 实拍四条 run 覆盖四态
64
+ (列表四个圆点、根行七项属性、`?run=demo-2` 直达、回填后**没有**提交、
65
+ **不推** `run-done` 时切走再切回来回复仍在)。
66
+ - ⚠️ `/api/runs/:id` 的响应多一个顶层 `note`(additive,`playTrace` 只读 `spans` / `traceId`)。
67
+
68
+ ## [0.10.9] - 2026-11-12
69
+
70
+ ### 变更
71
+
72
+ 调试面板的**两个渲染面各补一格**:右栏「回复正文」支持 GFM 表格,调用树展开的工具出参能把
73
+ **一整篇 Markdown** 渲染出来。无破坏性变更、无迁移动作。两包同步 0.10.8 → 0.10.9;
74
+ `@migor/agentia` 此次仍只有版本常量变化(`src/` 一行未动),实际改动全在 CLI 面板与
75
+ trace-view 渲染件。
76
+
77
+ - **两个面缺的是两件事,来源是同一份真实素材**:右栏正文里 `###` 渲染出来了、表格却是一行行
78
+ `| # | 错误内容 |` 加 `|---|`;调用树里展开的 `tool.output` 干脆整篇是源文
79
+ (`<!-- 迁移自 … -->`、`# JS 错误巡检`、`|---|---|` 全在)。
80
+ - **补 GFM 表格,并推翻当年那条「不做表格」的判断**:`markdown.ts` 头注当时明写「不做表格 /
81
+ 任务列表 / 嵌套列表 —— 面板是调试视图,不是文档站」。**那个判断是错的**:agent 的产出里
82
+ 「错误汇总 / 逐项统计 / 参数对照」天然就是表,砍掉它等于把最有信息量的几种结果打回源文,
83
+ 读者被迫在脑子里做列对齐。
84
+ - **成表的判据要两行同时成立、且列数相等**:只要「行里有 `|`」就成表,`a | b` 这种正文会被
85
+ 吞成表头;只要「下一行像分隔行」就成表,`---` 分隔线下面那段会被吃掉。列按表头对齐
86
+ (缺列补空、多列丢掉),`\|` 是格子里的字面竖线,对齐三档走 `:--` / `--:` / `:--:`,
87
+ 表格后紧跟的非 `|` 行立刻收尾(它没有围栏)。
88
+ - **HTML 注释丢弃、可跨行**:真实素材开头三四行常是 `<!-- 迁移自 … -->` 这类作者旁注,让它们
89
+ 以字面文本占掉首屏比不渲染更糟。**原文一个字节都不改** —— 隐藏只发生在渲染视图里;
90
+ **码块里的 `<!-- -->` 保留**:注释是注释,代码是代码。
91
+ - **一整篇 Markdown 现在是「字符串」,原先没有任何落点**:SKILL.md、巡检报告这类工具出参展开后
92
+ 是源文。现在走宿主注入 `createTraceView(root, { textBody })` —— 本包单独发布,反向
93
+ 依赖 CLI 不可能;让它自带一份解析器,仓库里就有两份必然漂移的。
94
+ - **「像不像 Markdown」的判据只认块级记号、且要两分**(围栏 / 表格各 2,标题 / 列表 / 引用各 1)。
95
+ **刻意不看行内记号**:`**kwargs` / `*args` 是 Python 源码里最常见的东西,按行内判会把整份
96
+ 源码判成 Markdown,而段落合并会**毁掉它的缩进**。超 100k 不渲染。判错的代价不是「少渲染」,
97
+ 是把一段本来好读的文本毁掉 ⇒ 宁漏不错(漏了还能看原文)。
98
+ - **注入的正文只负责画、不再铺树**:`createFullView(value, { body })` 给了 body 就原样挂,头部
99
+ 写 `string · N 字符` + `md` 标记(不与标记重复),「复制」取原文,`.tr-io` 仍持原文 ——
100
+ 与结构化块走**同一条挂载路径**,否则「哪种正文」会从外观上分叉成两套。
101
+ - **`textBody` 只在展开时调用、宿主抛错回落原文**:折叠态每条事件都重画整棵树,挂在那儿等于
102
+ 每来一条事件就把所有历史正文白解析一遍;调试面板不该因为一条坏正文白屏。
103
+ - ⚠️ **官网 Playground 不注入**(那里的工具出参保持原文)。这个不对称是**有意**的,写在
104
+ spec 里 —— 不写清楚就会在某个 PR 里被当成缺陷「顺手修掉」。
105
+
106
+ ### 新增:面板里的 Markdown(2026-11-12 ref-md-in-panel)
107
+
108
+ - 决策见 `docs/spec.md` §10 2026-11-12 ref-md-in-panel,守卫登记见 `docs/guards.md` §1。
109
+ - **验证**:CLI 252 → 257(表格 2、三种「像但不是」1、注释 1、接线 1)、trace-view
110
+ 90 → 96(注入件 2、接线 4)。**反向验证 8 条各自咬住**:去掉列数相等 / 去掉 `\|` 转义 /
111
+ 去掉跨行注释状态 / 把判据阈值降到 1;折叠态也调 `textBody` / 不把 body 交给块 / 忽略注入 /
112
+ 去掉宿主抛错的兜底。
113
+ - **顺带修一处守卫腐化**:trace-view 的「基线表不许重复键」用例原先按 `+ 2000` 字符切窗口,
114
+ 表一长尾部条目就滑出射程而断言照样绿 —— 正是「守卫看起来在管、实际不管」。改成按对象字面量
115
+ 的结束位置切。
116
+ - 行数棘轮逐项登记在 `packages/cli/test/structure.test.mjs` 与
117
+ `packages/trace-view/test/structure.test.js`:`markdown.ts` 296→453、`inspector-page.html`
118
+ 1493→1562、`view.js` 360→385、`full-view.js` 449→478、`trace-view.css` 375→386。
119
+
8
120
  ## [0.10.8] - 2026-11-12
9
121
 
10
122
  ### 变更
@@ -4509,7 +4621,9 @@ toolkit 142 / runtime 55 / engine 403 / container 10 / store 55 —— 全绿。
4509
4621
  首个公开发布:`@migor/agentia` + `@migor/cli`(scope `@migor/*`),两包版本同步。
4510
4622
  框架本体单包;CLI 独立成包(workspaces)。
4511
4623
 
4512
- [Unreleased]: https://github.com/migoric/agentia/compare/v0.10.8...HEAD
4624
+ [Unreleased]: https://github.com/migoric/agentia/compare/v0.10.10...HEAD
4625
+ [0.10.10]: https://github.com/migoric/agentia/releases/tag/v0.10.10
4626
+ [0.10.9]: https://github.com/migoric/agentia/releases/tag/v0.10.9
4513
4627
  [0.10.8]: https://github.com/migoric/agentia/releases/tag/v0.10.8
4514
4628
  [0.10.7]: https://github.com/migoric/agentia/releases/tag/v0.10.7
4515
4629
  [0.10.6]: https://github.com/migoric/agentia/releases/tag/v0.10.6
@@ -234,7 +234,14 @@ export function update(state, ev) {
234
234
  lastError = null;
235
235
  const effects = [{ kind: 'disarm-abort-timer' }];
236
236
  if (ev.traceId !== null && note !== null) {
237
- effects.push({ kind: 'note-run', traceId: ev.traceId, note });
237
+ // 回复(`finalText`)只有这一帧带 ⇒ 必须与 note 一起落账,否则面板切走再切回来
238
+ // 就再也取不到了(它不在 trace 上)。新建对象而不是改 `note`:pendingNote 是
239
+ // 上一事件的 state,就地改会让「同一份 note 被两条 run 共享」成为可能。
240
+ effects.push({
241
+ kind: 'note-run',
242
+ traceId: ev.traceId,
243
+ note: ev.finalText ? { ...note, finalText: ev.finalText } : note,
244
+ });
238
245
  }
239
246
  effects.push({
240
247
  kind: 'emit',
@@ -147,44 +147,71 @@ export function playTrace(view, trace) {
147
147
  }
148
148
  items.sort((a, b) => a.t - b.t || a.ord - b.ord || a.d - b.d || a.seq - b.seq);
149
149
 
150
- const rootRaw = String(root.name || 'run');
151
- // 框架的 run 根名可能是 runName(如 'app')或已是 'run · x' 形态,避免重复前缀
152
- view.reset(rootRaw.startsWith('run') ? rootRaw : 'run · ' + rootRaw);
153
-
154
- for (const it of items) {
155
- if (it.kind === 'start') {
156
- if (it.s === root) continue; // 根由 reset 建,不重复开
157
- view.start({
158
- id: it.s.spanId,
159
- parent: it.s.parentSpanId,
160
- kind: it.s.kind,
161
- name: displayName(it.s),
162
- arg: '',
163
- });
164
- } else if (it.kind === 'event') {
165
- view.event(
166
- it.s.spanId,
167
- it.ev.name,
168
- eventToolName(it.ev.name, it.ev.body?.tool, typeMap),
169
- eventText(it.ev),
170
- it.ev.body?.ok !== false,
171
- eventFull(it.ev),
172
- );
173
- } else {
174
- view.end({
175
- id: it.s.spanId,
176
- ms: it.s.endedAt - it.s.startedAt,
177
- usage: usageOf(it.s),
178
- status: it.s.status,
179
- error: it.s.error,
180
- arg: '',
181
- });
150
+ /** 把归一好的动作逐条喂给视图(不含 reset / finish —— 那两个由外层一起包进批里) */
151
+ const replay = () => {
152
+ for (const it of items) {
153
+ if (it.kind === 'start') {
154
+ if (it.s === root) continue; // 根由 reset 建,不重复开
155
+ view.start({
156
+ id: it.s.spanId,
157
+ parent: it.s.parentSpanId,
158
+ kind: it.s.kind,
159
+ name: displayName(it.s),
160
+ arg: '',
161
+ startedAt: it.s.startedAt,
162
+ // 属性(model / system.version / tools.names / retry.attempt / handoff.* / …):
163
+ // 树上一格都不显示的话,「这次是不是提示词版本没换」只能靠猜(2026-11-13)
164
+ attributes: it.s.attributes,
165
+ });
166
+ } else if (it.kind === 'event') {
167
+ view.event(
168
+ it.s.spanId,
169
+ it.ev.name,
170
+ eventToolName(it.ev.name, it.ev.body?.tool, typeMap),
171
+ eventText(it.ev),
172
+ it.ev.body?.ok !== false,
173
+ eventFull(it.ev),
174
+ );
175
+ } else {
176
+ view.end({
177
+ id: it.s.spanId,
178
+ ms: it.s.endedAt - it.s.startedAt,
179
+ endedAt: it.s.endedAt,
180
+ usage: usageOf(it.s),
181
+ status: it.s.status,
182
+ error: it.s.error,
183
+ arg: '',
184
+ });
185
+ }
182
186
  }
183
- }
187
+ };
184
188
 
185
- // 根 span 还没结束(`span.end` 尚未到达)⇒ **不收尾**:`finish()` 会把根标成已完成
186
- // (● + 耗时),而这棵树其实还在长 —— 在飞时看到「已完成」是假事实。留 ◌ 才对。
187
- // 收尾的整棵 trace 根必有 `endedAt`,所以这条分支只对「在飞 / 截断」的树生效。
188
- if (root.endedAt != null) view.finish(root.endedAt - root.startedAt, root.status, root.error);
189
+ const rootRaw = String(root.name || 'run');
190
+ /* 整个回放(含 reset 与收尾)包在**一次**重画里(2026-11-13):此前每个动作都触发整树
191
+ 重建 ⇒ O(N²)(实测 600 span = 1801 次全树重建、假 DOM 就 172ms)。语义不变 ——
192
+ 渲染仍是全量重建(那是折叠态必须外部持有的原因),只是把 N 次压成 1 次。
193
+ ⚠️ `reset` 也必须在批内:它在批外会先白画一遍空树,收尾再画一遍 ⇒ 2 次。
194
+ 没有 `batch` 的宿主(自定义 view 对象 / 老版本)退回逐条画,行为不变。 */
195
+ const replayWithFinish = () => {
196
+ // 框架的 run 根名可能是 runName(如 'app')或已是 'run · x' 形态,避免重复前缀。
197
+ // 第二个参数是**真 spanId**:根上的事件(score / trace.truncated / approval.decided)
198
+ // 按 spanId 投递,只登记字面量 'root' 会让它们全部静默丢失(见 view.js 的 reset)。
199
+ // 第三个是**根起点**:时间轴按它归一化(缺了就整棵树不画条)。
200
+ // 第四个是**根的 attributes**:`model` / `system.version` / `tools.names` 只写在根上,
201
+ // 而根行由 reset 建、不走 `view.start()` —— 不在这里递进去,这些键树上一格都没有。
202
+ view.reset(
203
+ rootRaw.startsWith('run') ? rootRaw : 'run · ' + rootRaw,
204
+ String(root.spanId || ''),
205
+ typeof root.startedAt === 'number' ? root.startedAt : undefined,
206
+ root.attributes,
207
+ );
208
+ replay();
209
+ /* 根 span 还没结束(`span.end` 尚未到达)⇒ **不收尾**:`finish()` 会把根标成已完成
210
+ (● + 耗时),而这棵树其实还在长 —— 在飞时看到「已完成」是假事实。留 ◌ 才对。
211
+ 收尾的整棵 trace 根必有 `endedAt`,所以这条分支只对「在飞 / 截断」的树生效。 */
212
+ if (root.endedAt != null) view.finish(root.endedAt - root.startedAt, root.status, root.error);
213
+ };
214
+ if (typeof view.batch === 'function') view.batch(replayWithFinish);
215
+ else replayWithFinish();
189
216
  return true;
190
217
  }
@@ -20,6 +20,11 @@
20
20
  * 序列化后又塞回 `content` 的工具 —— 这些值的类型是**字符串**,此前只显示成一串
21
21
  * `{\"a\":1` 的反斜杠汤,比无缩进的 JSON 更难读。现在首尾是 `{`…`}` / `[`…`]` 且解析成功的
22
22
  * 字符串按容器铺开,并在行里打 `str→json` 标记 —— 不标出来,读者会以为工具真的返回了对象。
23
+ * 5. **一整篇 Markdown 的字符串正文**(2026-11-12 晚):工具出参的另一种常见形态就是一份
24
+ * SKILL.md / 错误汇总报告,类型仍是字符串 ⇒ 展开后是 `# 标题`、`|---|---|`、
25
+ * `<!-- 迁移自 … -->` 的源文。本件**不**自带解析器 —— 那会是仓库里的第二份,必然与宿主
26
+ * 那份漂移,所以只接受宿主渲染好的元素(`opts.body`),自己负责头部
27
+ * (`string · N 字符 · md` + `md` 标记)与「复制取原文」。
23
28
  *
24
29
  * ## 边界(都是「不这么做就会坏」的地方,不是口味)
25
30
  *
@@ -44,6 +49,8 @@
44
49
  * 数字/空值/布尔就是**改写工具返回的事实**。解析结果内部的字符串**不再二次解析**:
45
50
  * 一层套一层是业务里的真实现象(SSE data 里再套 JSON),逐层展开会让 10KB 变成十万个节点。
46
51
  * 表格单元格也不解析 —— 同构表已经放弃了逐层展开,单元格里放不下嵌套块。
52
+ * - **宿主正文不再解析**:`opts.body` 给了就**原样用**。同一段内容既渲染又铺树,读者会看到
53
+ * 两套互相矛盾的呈现 —— 那比不做这一档更糟。所以这条判据只有一处(`createFullView`)。
47
54
  *
48
55
  * 零依赖、纯 DOM 构造(只用 createElement / appendChild / textContent / className /
49
56
  * dataset / innerHTML=''),与 view.js 共用同一套 DOM stub 跑测试。
@@ -170,6 +177,14 @@ function jsonBadge() {
170
177
  return b;
171
178
  }
172
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
+
173
188
  /** 子键的 JSON 路径:对象用 `.key`,数组用 `[i]`。根是空串,所以 `[0]` 不会变成 `.[0]` */
174
189
  function pathOf(parent, key, isArr) {
175
190
  return isArr ? `${parent}[${key}]` : parent ? `${parent}.${key}` : key;
@@ -401,48 +416,62 @@ function renderInto(ctx, v, depth, path, allowParse = true) {
401
416
  /**
402
417
  * 建一个结构化正文块。
403
418
  * @param {any} value `ev.full` 的**原值**(不是序列化后的字符串)
404
- * @param {{fold?: Map<string, boolean>}} [opts]
419
+ * @param {{fold?: Map<string, boolean>, body?: Element}} [opts]
405
420
  * `fold` 是**调用方持有**的折叠状态(路径 → 是否展开)。省略时本块自建一份,
406
421
  * 此时折叠状态活不过下一次「全量重建」—— 只对一次性渲染(测试、静态快照)够用。
422
+ * `body` 是**宿主已经渲染好的正文**(如 Markdown):给了就原样用,不再走缩进/折叠,
423
+ * 本件只负责头部(类型 / `md` 标记 / 复制)与挂载位置。
407
424
  * @returns {Element} `.tr-full` 块;调用方负责 append 到展开行之后
408
425
  */
409
426
  export function createFullView(value, opts = {}) {
410
427
  const fold = opts.fold instanceof Map ? opts.fold : new Map();
411
-
412
- // 根是字符串 ⇒ 只有「里面装着 JSON」才会进来(调用方用 hasStructuredBody 先筛过)。
413
- // 头部同时写出**两件事**:它原本是字符串(字符数)、解析后是什么规模 —— 只写后者,
414
- // 读者会以为工具真的返回了对象。
415
- const parsedRoot = jsonInString(value);
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);
416
436
  const root = parsedRoot ?? value;
417
437
  const fromJson = parsedRoot !== null;
418
438
 
419
439
  const box = fvEl('div', 'tr-full');
420
- box.dataset.kind = Array.isArray(root) ? 'array' : 'object';
440
+ box.dataset.kind = injected ? 'md' : Array.isArray(root) ? 'array' : 'object';
421
441
 
422
442
  const head = fvEl('div', 'tr-full-head');
423
443
  const label = typeLabelOf(root);
424
- head.appendChild(
425
- fvEl('span', 'tr-full-type', fromJson ? `string · ${value.length} 字符 → ${label}` : label),
426
- );
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));
427
450
  if (fromJson) head.appendChild(jsonBadge());
451
+ if (injected) head.appendChild(mdBadge());
428
452
  const copy = fvEl('span', 'tr-full-copy', '复制');
429
453
  copy.dataset.copy = '1';
430
- // 字符串根复制的是**原文**(拿到手能直接喂 jq);容器根复制的是缩进后的 JSON
454
+ // 字符串根复制的是**原文**(拿到手能直接喂 jq / 贴回上游);容器根复制的是缩进后的 JSON
431
455
  copy.addEventListener('click', () =>
432
- copyInto(copy, fromJson ? String(value) : safeStringify(root), '已复制'),
456
+ copyInto(copy, fromJson || injected ? String(value) : safeStringify(root), '已复制'),
433
457
  );
434
458
  head.appendChild(copy);
435
459
  box.appendChild(head);
436
460
 
437
461
  const body = fvEl('div', 'tr-full-body');
438
- const ctx = { body, fold, repaint: null };
439
- // 只重画正文(不动头部):折叠不需要重建整棵树,滚动位置与头部反馈都留着
440
- ctx.repaint = () => {
441
- body.innerHTML = '';
442
- // 根若是从字符串解析来的,它的子树不再二次解析(只解析一层,防嵌套放大)
443
- renderInto(ctx, root, 0, '', !fromJson);
444
- };
445
- ctx.repaint();
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
+ }
446
475
 
447
476
  box.appendChild(body);
448
477
  return box;