virlen-remote 0.5.0 → 0.7.0

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +373 -169
  2. package/README.md +389 -201
  3. package/dist/index.d.ts +10 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +10 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/protocol/agents.d.ts +14 -0
  8. package/dist/protocol/agents.d.ts.map +1 -0
  9. package/dist/protocol/agents.js +14 -0
  10. package/dist/protocol/agents.js.map +1 -0
  11. package/dist/protocol/api.d.ts +370 -1
  12. package/dist/protocol/api.d.ts.map +1 -1
  13. package/dist/protocol/api.js.map +1 -1
  14. package/dist/protocol/compress.d.ts +55 -0
  15. package/dist/protocol/compress.d.ts.map +1 -0
  16. package/dist/protocol/compress.js +60 -0
  17. package/dist/protocol/compress.js.map +1 -0
  18. package/dist/protocol/files.d.ts +261 -0
  19. package/dist/protocol/files.d.ts.map +1 -0
  20. package/dist/protocol/files.js +506 -0
  21. package/dist/protocol/files.js.map +1 -0
  22. package/dist/protocol/host.d.ts +24 -1
  23. package/dist/protocol/host.d.ts.map +1 -1
  24. package/dist/protocol/host.js +9 -1
  25. package/dist/protocol/host.js.map +1 -1
  26. package/dist/protocol/message-files.d.ts +79 -0
  27. package/dist/protocol/message-files.d.ts.map +1 -0
  28. package/dist/protocol/message-files.js +118 -0
  29. package/dist/protocol/message-files.js.map +1 -0
  30. package/dist/protocol/tool-args.d.ts +95 -0
  31. package/dist/protocol/tool-args.d.ts.map +1 -0
  32. package/dist/protocol/tool-args.js +439 -0
  33. package/dist/protocol/tool-args.js.map +1 -0
  34. package/dist/testing/mock-host.d.ts +44 -1
  35. package/dist/testing/mock-host.d.ts.map +1 -1
  36. package/dist/testing/mock-host.js +439 -10
  37. package/dist/testing/mock-host.js.map +1 -1
  38. package/dist/transport/rtc.d.ts +17 -0
  39. package/dist/transport/rtc.d.ts.map +1 -1
  40. package/dist/transport/rtc.js +49 -12
  41. package/dist/transport/rtc.js.map +1 -1
  42. package/dist/transport/signaling.d.ts +12 -1
  43. package/dist/transport/signaling.d.ts.map +1 -1
  44. package/dist/transport/signaling.js +17 -3
  45. package/dist/transport/signaling.js.map +1 -1
  46. package/package.json +69 -69
  47. package/src/index.ts +270 -184
  48. package/src/protocol/agents.ts +13 -0
  49. package/src/protocol/api.ts +974 -615
  50. package/src/protocol/compress.ts +65 -0
  51. package/src/protocol/files.ts +544 -0
  52. package/src/protocol/host.ts +205 -165
  53. package/src/protocol/message-actions.ts +60 -60
  54. package/src/protocol/message-files.ts +161 -0
  55. package/src/protocol/pairing.ts +254 -254
  56. package/src/protocol/tool-args.ts +478 -0
  57. package/src/testing/mock-host.ts +1285 -740
  58. package/src/transport/link-kind.ts +222 -222
  59. package/src/transport/rtc.ts +381 -344
  60. package/src/transport/signaling.ts +331 -317
  61. package/src/transport/transfer-tier.ts +64 -64
@@ -0,0 +1,478 @@
1
+ /**
2
+ * 工具入参摘要 —— 「这一步工具到底在干什么」的**唯一格式化口径**。
3
+ *
4
+ * 为什么单列一个模块:手机屏上一行只放得下一步工具调用,而**哪几个键才是关键入参**是随
5
+ * 工具而变的(`read_file` 看路径、`execute_command` 看命令、`search_text_in_files` 看
6
+ * 「在哪儿搜什么」)。这份知识原先只存在于桌面端的 28 个 `getShortText()` 组件里 ——
7
+ * 手机端拿不到,于是真机反馈是「工具气泡只有工具名,看不到编辑的是哪个文件、执行的是什么命令」。
8
+ *
9
+ * 三条纪律(与桌面 `tool-call/primary-path.ts` 同源):
10
+ *
11
+ * 1. **按工具名取键**:键名以 `tool_defs/definitions.json` 为准(那是权威契约,本文件
12
+ * 只挑「主参数」)——不用「给每个工具加一个方法」那种写法,那是把同一件事抄 28 遍;
13
+ * 2. **拿不到就不猜**:没有可用的关键入参 → `undefined`,手机端只显示工具名(宁可不显示,
14
+ * 也不要显示一句听起来像那么回事的假话);
15
+ * 3. **只给摘要,不给原文**:`write_file.content` / `edit_file.edits[].old_string` 可能是
16
+ * 整篇文章,原样下行就是流量事故(§7-⑦ 同一条纪律)。长度有硬上限,超长一律截断。
17
+ *
18
+ * 产出是**纯文本、纯函数**:同一份实现同时供真实电脑侧(`bridge/dto.ts`)与演示宿主
19
+ * (`testing/mock-host.ts`)使用,两端不会漂移。
20
+ *
21
+ * 本模块承担工具调用的**两种呈现**(两级粒度,别混用):
22
+ * - {@link summarizeToolArgs} —— **折叠态**的一行摘要(硬上限 {@link TOOL_ARGS_MAX} = 160);
23
+ * - {@link formatToolArgs} —— **展开态**的完整入参(上限 {@link TOOL_DETAIL_MAX} = 5000,
24
+ * 超出走中间省略 {@link elideMiddle})。
25
+ * 两级共用同一个 `shortenPath` 回调:同一份路径不能在折叠态与展开态显示成两个样子。
26
+ */
27
+
28
+ /** 摘要长度硬上限:手机一行放不下更多,也避免长命令 / 长路径把帧撑大。 */
29
+ export const TOOL_ARGS_MAX = 160
30
+
31
+ /**
32
+ * 各工具的「主参数」键名(按优先级),键名取自 `tool_defs/definitions.json`。
33
+ *
34
+ * 只列**需要与兜底顺序不同**的工具:其余工具(`file_info` / `vision_analyze` / `mkdir` …)
35
+ * 的主参数就是 `path`,交给下面的通用规则即可。
36
+ */
37
+ const PRIMARY_KEYS: Record<string, readonly string[]> = {
38
+ read_file: ['path', 'paths'],
39
+ delete_file: ['path', 'paths'],
40
+ mkdir: ['path', 'paths'],
41
+ execute_script: ['file_path'],
42
+ search_knowledge_base: ['query'],
43
+ list_knowledge_base_documents: ['knowledge_base_id'],
44
+ get_knowledge_base_document: ['document_id', 'knowledge_base_id'],
45
+ write_to_knowledge_base: ['document_name'],
46
+ delete_knowledge_base_document: ['document_id', 'knowledge_base_id'],
47
+ web_search: ['query'],
48
+ web_fetch: ['url'],
49
+ read_skill_source: ['name'],
50
+ read_messages: ['message_id'],
51
+ list_messages: ['keyword'],
52
+ user_choice: ['question'],
53
+ }
54
+
55
+ /**
56
+ * 兜底顺序 —— 未知工具(协议比工具集新 / 旧)也给出一个像样的摘要。
57
+ *
58
+ * `path` 在 `query` 之前:与桌面 `DefaultMessage` 的取舍一致(它也是优先取 `path`),
59
+ * 这种「同一种入参键在两端必须是同一个含义」的规则不该各写一份。
60
+ */
61
+ const FALLBACK_KEYS = [
62
+ 'command',
63
+ 'path',
64
+ 'paths',
65
+ 'file_path',
66
+ 'url',
67
+ 'query',
68
+ 'keyword',
69
+ 'name',
70
+ 'document_name',
71
+ 'document_id',
72
+ 'knowledge_base_id',
73
+ 'message_id',
74
+ 'question',
75
+ ]
76
+
77
+ export interface ToolArgsSummaryOptions {
78
+ /**
79
+ * 路径缩短回调(电脑侧传 `toShortPath(path, workspace)`,与桌面卡片同一口径)。
80
+ * 不传 = 原样显示 —— 演示宿主 / 单测不需要工作目录上下文。
81
+ */
82
+ shortenPath?: (path: string) => string
83
+ /** 摘要上限,默认 {@link TOOL_ARGS_MAX}。 */
84
+ maxLength?: number
85
+ }
86
+
87
+ /**
88
+ * 生成一行入参摘要;没有可显示的内容时返回 `undefined`。
89
+ *
90
+ * 未知工具走兜底键序;一个都不命中时用「键=值」拼(最多两个键)——那是桌面
91
+ * `DefaultMessage` 的行为,手机端不需要比它更聪明,但需要至少能看出「调了什么」。
92
+ */
93
+ export function summarizeToolArgs(
94
+ name: string,
95
+ input: unknown,
96
+ options: ToolArgsSummaryOptions = {},
97
+ ): string | undefined {
98
+ const max = options.maxLength ?? TOOL_ARGS_MAX
99
+ // 入参可能是 JSON 字符串(引擎透传 / 老数据),先解开再当对象用
100
+ const record = asRecord(input)
101
+ if (!record) {
102
+ /*
103
+ * 看起来像 JSON 却解不开(老数据可能被截断)→ **不给摘要**:半截 JSON 不是信息,
104
+ * 是噪音 —— 手机屏上一行「{ "path": "src/a」除了占地方什么也说明不了。
105
+ */
106
+ if (typeof input === 'string' && looksLikeJson(input)) return undefined
107
+ const text = scalarText(input)
108
+ return text ? clip(collapse(text), max) : undefined
109
+ }
110
+
111
+ const short = options.shortenPath ?? ((path: string) => path)
112
+ const text = summarize(name, record, short)
113
+ return text ? clip(collapse(text), max) : undefined
114
+ }
115
+
116
+ function summarize(
117
+ name: string,
118
+ rec: Record<string, unknown>,
119
+ short: (path: string) => string,
120
+ ): string | undefined {
121
+ switch (name) {
122
+ // 命令就是这件事本身(tips 是模型的解释,放不进行内摘要,留给桌面卡片)
123
+ case 'execute_command':
124
+ return scalarText(rec.command)
125
+
126
+ case 'copy_move_file': {
127
+ const source = pathText(rec.source, short)
128
+ const dest = pathText(rec.destination, short)
129
+ if (!source && !dest) return undefined
130
+ if (source && dest) return `${source} → ${dest}`
131
+ return source || dest
132
+ }
133
+
134
+ /*
135
+ * 搜索类:桌面文案是「在 X 中搜索 Y」(`SearchTextInFileMessage` /
136
+ * `SearchFileByNameMessage`),手机沿用同一句式 —— 用户在两个屏幕上看到同一句话,
137
+ * 才不需要重新理解一次。
138
+ */
139
+ case 'search_text_in_files':
140
+ case 'search_files_by_name': {
141
+ const root = pathText(rec.path, short)
142
+ const query = scalarText(rec.query)
143
+ if (root && query) return `在 ${root} 中搜索 ${query}`
144
+ return query ?? root
145
+ }
146
+
147
+ case 'read_file': {
148
+ const paths = pathList(rec, short)
149
+ if (!paths) return undefined
150
+ const range = readRange(rec)
151
+ return range ? `${paths} ${range}` : paths
152
+ }
153
+
154
+ case 'write_file': {
155
+ const path = pathText(rec.path, short)
156
+ if (!path) return undefined
157
+ const lines = typeof rec.content === 'string' ? countLines(rec.content) : 0
158
+ return lines > 0 ? `${path} · 写入 ${lines} 行` : path
159
+ }
160
+
161
+ case 'edit_file': {
162
+ const path = pathText(rec.path, short)
163
+ const stat = editStat(rec)
164
+ if (!path) return stat
165
+ return stat ? `${path} · ${stat}` : path
166
+ }
167
+
168
+ // 清单只在桌面的标题栏浮层里看;消息流这一行只需说明「改了流程」(桌面同一句文案)
169
+ case 'todo_write': {
170
+ const todos = rec.todos
171
+ if (Array.isArray(todos)) return `更新了 ${todos.length} 项任务`
172
+ return undefined
173
+ }
174
+
175
+ default:
176
+ break
177
+ }
178
+
179
+ const keys = PRIMARY_KEYS[name] ?? FALLBACK_KEYS
180
+ for (const key of keys) {
181
+ const value = rec[key]
182
+ if (Array.isArray(value)) {
183
+ const list = listText(value, short)
184
+ if (list) return list
185
+ continue
186
+ }
187
+ const text = pathOrScalar(key, value, short)
188
+ if (text) return text
189
+ }
190
+
191
+ return objectFallback(rec, short)
192
+ }
193
+
194
+ /** 一个键都不命中时的兜底:最多两个「键=值」,仍然比一整串 JSON 好读。 */
195
+ function objectFallback(
196
+ rec: Record<string, unknown>,
197
+ short: (path: string) => string,
198
+ ): string | undefined {
199
+ const parts: string[] = []
200
+ for (const key of Object.keys(rec)) {
201
+ if (parts.length >= 2) break
202
+ const value = rec[key]
203
+ if (Array.isArray(value)) continue
204
+ const text = pathOrScalar(key, value, short)
205
+ if (text) parts.push(`${key}=${text}`)
206
+ }
207
+ return parts.length ? parts.join(' ') : undefined
208
+ }
209
+
210
+ /** 路径类键名(这些键的值要过 `shortenPath`,其余原样)。 */
211
+ const PATH_KEYS = new Set(['path', 'paths', 'file_path', 'source', 'destination'])
212
+
213
+ function pathOrScalar(
214
+ key: string,
215
+ value: unknown,
216
+ short: (path: string) => string,
217
+ ): string | undefined {
218
+ if (PATH_KEYS.has(key)) return pathText(value, short)
219
+ return scalarText(value)
220
+ }
221
+
222
+ function pathText(value: unknown, short: (path: string) => string): string | undefined {
223
+ if (typeof value !== 'string') return undefined
224
+ const trimmed = value.trim()
225
+ return trimmed ? short(trimmed) : undefined
226
+ }
227
+
228
+ /** 路径列表:首项 + `+N`(桌面也是一样只报首项与总数,不铺开一串)。 */
229
+ function listText(values: readonly unknown[], short: (path: string) => string): string | undefined {
230
+ const items: string[] = []
231
+ for (const value of values) {
232
+ const text = pathText(value, short)
233
+ if (text) items.push(text)
234
+ }
235
+ if (items.length === 0) return undefined
236
+ if (items.length === 1) return items[0]
237
+ return `${items[0]} +${items.length - 1}`
238
+ }
239
+
240
+ /** `path` / `paths` 两种形状统一成一行(批量形态优先:它是这次调用真正的覆盖面)。 */
241
+ function pathList(rec: Record<string, unknown>, short: (path: string) => string): string | undefined {
242
+ if (Array.isArray(rec.paths)) {
243
+ const text = listText(rec.paths, short)
244
+ if (text) return text
245
+ }
246
+ return pathText(rec.path, short)
247
+ }
248
+
249
+ /** `read_file` 的读取范围:`start_line` / `max_lines` 都已知时给出行区间。 */
250
+ function readRange(rec: Record<string, unknown>): string | undefined {
251
+ const start = numberText(rec.start_line)
252
+ if (!start) return undefined
253
+ const max = numberText(rec.max_lines)
254
+ return max ? `${start}-${start + max - 1}` : `${start}`
255
+ }
256
+
257
+ /**
258
+ * `edit_file` 的改动规模。
259
+ *
260
+ * 与桌面 `EditFileMessage` 的**降级分支**同一条算法(没有工具结果 uiData 时按入参行数估):
261
+ * 电脑侧手上只有入参,拿不到那次编辑的精确 diff —— 于是如实给一个「按行数估」的说法,
262
+ * 文案与桌面逐字一致(`减少 X行,新增 Y行`)。
263
+ */
264
+ function editStat(rec: Record<string, unknown>): string | undefined {
265
+ let del = 0
266
+ let ins = 0
267
+ const edits = Array.isArray(rec.edits)
268
+ ? rec.edits
269
+ : [{ old_string: rec.old_string, new_string: rec.new_string }]
270
+ for (const edit of edits) {
271
+ const item = asRecord(edit)
272
+ if (!item) continue
273
+ if (typeof item.old_string === 'string') del += countLines(item.old_string)
274
+ if (typeof item.new_string === 'string') ins += countLines(item.new_string)
275
+ }
276
+ const parts: string[] = []
277
+ if (del > 0) parts.push(`减少 ${del}行`)
278
+ if (ins > 0) parts.push(`新增 ${ins}行`)
279
+ return parts.length ? parts.join(',') : undefined
280
+ }
281
+
282
+ /** 行数(末尾空行不算,与手机端 `countLines` 同一口径)。 */
283
+ function countLines(text: string): number {
284
+ const body = text.replace(/\n+$/, '')
285
+ return body.trim() ? body.split('\n').length : 0
286
+ }
287
+
288
+ /** 标量 → 文本:字符串 / 数字 / 布尔;其余(对象、数组、null)返回 `undefined`。 */
289
+ function scalarText(value: unknown): string | undefined {
290
+ if (typeof value === 'string') return value.trim() || undefined
291
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
292
+ if (typeof value === 'boolean') return String(value)
293
+ return undefined
294
+ }
295
+
296
+ /** 数字键(`start_line` 之类):只认正整数,`0` / 负数 / NaN 一律当没给。 */
297
+ function numberText(value: unknown): number | undefined {
298
+ const n = typeof value === 'string' ? Number(value) : value
299
+ return typeof n === 'number' && Number.isInteger(n) && n > 0 ? n : undefined
300
+ }
301
+
302
+ /** 对象视图;JSON 字符串也认(宽松解析失败就返回 `undefined`,不抛)。 */
303
+ function asRecord(input: unknown): Record<string, unknown> | undefined {
304
+ if (input && typeof input === 'object' && !Array.isArray(input)) {
305
+ return input as Record<string, unknown>
306
+ }
307
+ if (typeof input === 'string') {
308
+ const trimmed = input.trim()
309
+ if (!trimmed.startsWith('{')) return undefined
310
+ try {
311
+ const parsed = JSON.parse(trimmed)
312
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
313
+ return parsed as Record<string, unknown>
314
+ }
315
+ } catch {
316
+ return undefined
317
+ }
318
+ }
319
+ return undefined
320
+ }
321
+
322
+ /** 看着像 JSON(对象 / 数组):用于区分「一段普通文本」与「解不开的结构化入参」。 */
323
+ function looksLikeJson(text: string): boolean {
324
+ const trimmed = text.trim()
325
+ return trimmed.startsWith('{') || trimmed.startsWith('[')
326
+ }
327
+
328
+ /** 换行 / 连续空白压成单个空格:多行命令与 JSON 片段在行内摘要里必须是一行。 */
329
+ function collapse(text: string): string {
330
+ return text.replace(/\s+/g, ' ').trim()
331
+ }
332
+
333
+ function clip(text: string, max: number): string {
334
+ return text.length > max ? `${text.slice(0, max)}…` : text
335
+ }
336
+
337
+ // ─────────────────── 展开态的「完整入参」与通用中间省略 ───────────────────
338
+
339
+ /**
340
+ * **展开区**文本的长度上限 —— 入参详情与工具输出**共用同一条线**。
341
+ *
342
+ * 与 {@link TOOL_ARGS_MAX} 不是一个量级,也不是一回事:那个是**折叠态一行**能放下多少字,
343
+ * 这个是**展开后**愿意渲染多少字(用户 2026-10 拍板:超了就中间省略)。
344
+ *
345
+ * 为什么是 5000:手机一屏约 30 行 × 40 字,5000 字符已是四屏以上;再长就不叫「看」,
346
+ * 叫「拖着滚」—— 而拖动过程中真正需要的信息(开头在做什么、结尾成没成)都在两头,
347
+ * 中间那段从来没人读完过。
348
+ */
349
+ export const TOOL_DETAIL_MAX = 5000
350
+
351
+ /**
352
+ * 中间省略 —— 头尾都留,挖掉中间。
353
+ *
354
+ * 为什么不「从尾部截断」:工具输出与入参的**结论都在两头** —— 开头是命令 / 标题 / 第一个
355
+ * 文件,结尾是报错 / 汇总 / 最后一个 diff 块。只留头等于把结论切掉,用户还得回电脑前看。
356
+ *
357
+ * ⚠️ 被省略的字符数写进正文里(`…(中间省略 N 字符)…`)——这是「这里少了东西」的
358
+ * **唯一凭证**:只看长度看不出来,一段刚好的 5000 字符与被砍过的 5000 字符无法区分。
359
+ */
360
+ export function elideMiddle(text: string, max: number = TOOL_DETAIL_MAX): string {
361
+ if (max <= 0) return ''
362
+ if (text.length <= max) return text
363
+ // 标记本身也要占额度:先按最坏情况留出余量,末尾再兜一次硬截(结果绝不超上限)
364
+ const keep = Math.max(2, max - MARKER_RESERVE)
365
+ const headLength = Math.ceil(keep / 2)
366
+ const head = text.slice(0, headLength)
367
+ const tail = text.slice(text.length - (keep - headLength))
368
+ const omitted = text.length - head.length - tail.length
369
+ const result = `${head}\n\n${omittedMark(omitted)}\n\n${tail}`
370
+ return result.length <= max ? result : result.slice(0, max)
371
+ }
372
+
373
+ /** {@link elideMiddle} 的最坏长度预留(标记行 + 首尾换行),用于算「还能留多少原文字符」。 */
374
+ const MARKER_RESERVE = 32
375
+
376
+ function omittedMark(omitted: number): string {
377
+ return `…(中间省略 ${omitted} 字符)…`
378
+ }
379
+
380
+ /**
381
+ * {@link formatToolArgs} 的可选项 —— 与 {@link ToolArgsSummaryOptions} 是同一种上下文。
382
+ */
383
+ export interface ToolArgsFormatOptions {
384
+ /**
385
+ * 路径缩短回调(电脑侧传 `toShortPath(path, workspace)`)。
386
+ *
387
+ * ⚠️ 必须与 {@link summarizeToolArgs} 传的是**同一个**回调:折叠态显示 `src/a.ts`、
388
+ * 展开后变成 `E:/code/demo/src/a.ts`,用户会以为点开卡片改坏了什么。
389
+ */
390
+ shortenPath?: (path: string) => string
391
+ /** 上限,默认 {@link TOOL_DETAIL_MAX}(超出中间省略)。 */
392
+ maxLength?: number
393
+ }
394
+
395
+ /**
396
+ * 工具入参的**完整文本**(展开区用):两空格缩进的 JSON,路径按工作目录缩短,超长中间省略。
397
+ *
398
+ * 为什么不把 {@link summarizeToolArgs} 的输出「加长」:摘要只挑**主参数**,而用户点开卡片
399
+ * 想看的恰恰是那行没显示完的部分 —— 还有哪些键、`write_file` 到底写了什么、`edit_file` 的
400
+ * old/new 是什么。唯一忠实的做法是把入参本身摆出来,于是这里与桌面导出
401
+ * (`ui/pages/chat/components/sidebar` 的 `JSON.stringify(tc.input, null, 2)`)用同一种形态。
402
+ *
403
+ * ⚠️ 与摘要「只给摘要、不给原文」的纪律**不同**:这里允许出现正文(`write_file.content`
404
+ * 会原样出现)—— 前提是用户**主动点开**才渲染,总量由 {@link TOOL_DETAIL_MAX} 兜住。
405
+ * 折叠态绝不带它,那才是流量事故的现场。
406
+ *
407
+ * 没有可显示的内容 → `undefined`(展开区不渲染这一块)。
408
+ */
409
+ export function formatToolArgs(
410
+ input: unknown,
411
+ options: ToolArgsFormatOptions = {},
412
+ ): string | undefined {
413
+ const max = options.maxLength ?? TOOL_DETAIL_MAX
414
+ const short = options.shortenPath ?? ((path: string) => path)
415
+ const value = normalizeDetail(input)
416
+ if (value === undefined || isEmptyDetail(value)) return undefined
417
+ const text =
418
+ typeof value === 'object'
419
+ ? JSON.stringify(shortenPathValues(value, short), null, 2)
420
+ : String(value)
421
+ const trimmed = (text ?? '').trim()
422
+ return trimmed ? elideMiddle(trimmed, max) : undefined
423
+ }
424
+
425
+ /**
426
+ * 归一化入参:JSON 字符串解开、普通文本原样、标量转文本,对象 / 数组原样。
427
+ *
428
+ * 解不开的 JSON 字符串**原样给出**(与摘要那边的取舍相反:摘要是「半截 JSON 不是信息」,
429
+ * 而展开区是用户点开来看的现场 —— 模型真发了一段坏 JSON,如实摆出来比藏起来有用)。
430
+ */
431
+ function normalizeDetail(input: unknown): unknown {
432
+ if (input === undefined || input === null) return undefined
433
+ if (typeof input === 'string') {
434
+ const trimmed = input.trim()
435
+ if (!trimmed) return undefined
436
+ if (!looksLikeJson(trimmed)) return trimmed
437
+ try {
438
+ return JSON.parse(trimmed)
439
+ } catch {
440
+ return trimmed
441
+ }
442
+ }
443
+ if (typeof input === 'object') return input
444
+ return scalarText(input)
445
+ }
446
+
447
+ /**
448
+ * 空入参(`{}` / `[]`)→ 没有可展示的内容。
449
+ *
450
+ * 为什么不是把它当「入参为空」显示出来:展开区那一块的唯一职责是回答「刚才调它的时候
451
+ * 到底传了什么」。无参数的工具(`get_current_time` 之类)传了个空对象 —— 摆一个 `{}`
452
+ * 只占地方,反而让用户以为漏显示了什么。
453
+ */
454
+ function isEmptyDetail(value: unknown): boolean {
455
+ if (Array.isArray(value)) return value.length === 0
456
+ if (value && typeof value === 'object') return Object.keys(value as object).length === 0
457
+ return false
458
+ }
459
+
460
+ /**
461
+ * 递归把**路径键**的值缩短(键名与 {@link PATH_KEYS} 同一份:`path` / `paths` / `file_path` …)。
462
+ */
463
+ function shortenPathValues(value: unknown, short: (path: string) => string): unknown {
464
+ if (Array.isArray(value)) return value.map((item) => shortenPathValues(item, short))
465
+ if (!value || typeof value !== 'object') return value
466
+ const out: Record<string, unknown> = {}
467
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
468
+ out[key] = PATH_KEYS.has(key) ? shortenPathValue(raw, short) : shortenPathValues(raw, short)
469
+ }
470
+ return out
471
+ }
472
+
473
+ /** 路径键的值:字符串 / 字符串数组缩短,其余按普通值递归。 */
474
+ function shortenPathValue(value: unknown, short: (path: string) => string): unknown {
475
+ if (typeof value === 'string') return short(value)
476
+ if (Array.isArray(value)) return value.map((item) => shortenPathValue(item, short))
477
+ return shortenPathValues(value, short)
478
+ }