@jetecho/dsh-csv-and-image-preview 0.1.1 → 0.1.3

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/lib/index.js CHANGED
@@ -1,434 +1,394 @@
1
- /**
2
- * dsh-csv-and-image-preview 宿主插件。
3
- *
4
- * 单条 Loader 行(见 cordis.patch.yml)挂载本模块。职责:
5
- * 1. 注册 `preview_image` 工具:给定一个文件路径(或内联 SVG),读取文件、
6
- * 推断 MIME、base64 编码为 data-URI;
7
- * 2. 注册 `preview_csv` 工具:读取 CSV/TSV(磁盘路径或内联文本),嗅探分隔符、
8
- * 有界解析(输入体积/行数/列数/单元格四重截断),产出可序列化的表格 meta;
9
- * 3. 两者都把展示数据通过工具结果的 `presentationMeta` 投递出去,浏览器端
10
- * 键控 toolview 据此渲染原生 <img> / <table> —— 绕过聊天渲染器的过滤;
11
- * 4. `execute` 返回紧凑的模型可读摘要,真正的图片字节/表格数据只走 meta,
12
- * 不进模型上下文。
13
- *
14
- * 注意:`output.presentationMeta` 必须**同步**返回 lossless JSON(它是展示
15
- * 期别名,在结果落地时就读取,不能返回 Promise),因此这里用同步 fs 读取;
16
- * `execute` 本身是 async,但内部调用同一个同步构建器。
17
- *
18
- * 不导入 cordis/dsh-* 运行时包中的 Service/Context 类:仅用 ctx API 与 Node
19
- * 内建能力,与宿主进程共享同一套运行时实例。
20
- * @module dsh-csv-and-image-preview
21
- */
22
-
23
- import { readFileSync, statSync } from 'node:fs'
1
+ /**
2
+ * dsh-csv-and-image-preview 宿主插件。
3
+ *
4
+ * 单条 Loader 行(见 cordis.patch.yml)挂载本模块。职责:
5
+ * 1. 注册 `preview_image` 工具:给定一个文件路径(或内联 SVG),读取文件、
6
+ * 推断 MIME、base64 编码为 data-URI;
7
+ * 2. 注册 `preview_csv` 工具:读取 CSV/TSV(磁盘路径或内联文本),嗅探分隔符、
8
+ * 有界解析(输入体积/行数/列数/单元格四重截断),产出可序列化的表格 meta;
9
+ * 3. 以会话和调用 ID 保存快照,通过已认证的 Connection RPC 投递到浏览器;
10
+ * 4. `execute` 仅返回摘要。直接调用和 run_code 子调用使用同一通道,
11
+ * 不依赖仅顶层调用才计算的 presentationMeta,文件也只读取一次。
12
+ *
13
+ * 不导入 cordis/dsh-* 运行时包中的 Service/Context 类:仅用 ctx API 与 Node
14
+ * 内建能力,与宿主进程共享同一套运行时实例。
15
+ * @module dsh-csv-and-image-preview
16
+ */
17
+
24
18
  import { resolve } from 'node:path'
19
+ import { PreviewStore, readBounded, MAX_INPUT_BYTES, executionIdentity, createPreviewRoute } from './store.js'
25
20
 
26
21
  export const name = 'csv-and-image-preview'
27
-
28
- /** 扩展名 → MIME 推断表。 */
29
- const MIME_BY_EXT = {
30
- '.svg': 'image/svg+xml',
31
- '.png': 'image/png',
32
- '.jpg': 'image/jpeg',
33
- '.jpeg': 'image/jpeg',
34
- '.gif': 'image/gif',
35
- '.webp': 'image/webp',
36
- '.bmp': 'image/bmp',
37
- '.ico': 'image/x-icon',
38
- '.avif': 'image/avif',
39
- }
40
-
41
- /** 从 args.label 取标题;空/缺省回退 fallback。 */
42
- function labelOf(args, fallback) {
43
- return typeof args === 'object' && args !== null && typeof args.label === 'string' && args.label !== ''
44
- ? args.label
45
- : fallback
46
- }
47
-
48
- // ── 图片预览 ────────────────────────────────────────────────────────────────
49
-
50
- /** 从路径扩得 MIME;未知回退 octet-stream。 */
51
- function mimeOf(path) {
52
- const dot = path.lastIndexOf('.')
53
- if (dot < 0) return 'application/octet-stream'
54
- return MIME_BY_EXT[path.slice(dot).toLowerCase()] ?? 'application/octet-stream'
55
- }
56
-
57
- /** 提取内联 SVG 的 width/height(viewBox 或 attr),用于摘要。 */
58
- function svgDimension(svg) {
59
- const w = /width="([0-9.]+)"/.exec(svg)
60
- const h = /height="([0-9.]+)"/.exec(svg)
61
- const vb = /viewBox="[^"]*?([0-9.]+)[ ,]+([0-9.]+)[ ,]+([0-9.]+)[ ,]+([0-9.]+)"/.exec(svg)
62
- if (w && h) return `${w[1]}×${h[1]}`
63
- if (vb) return `${vb[3]}×${vb[4]}`
64
- return 'unknown'
65
- }
66
-
67
- /** 模型可读的单行摘要。 */
68
- function imageSummary(mime) {
69
- return mime === 'image/svg+xml' ? 'SVG 矢量图' : mime.replace('image/', '').toUpperCase() + ' 位图'
70
- }
71
-
72
- /**
73
- * 同步构建一次预览结果,返回 { src, mime, label, bytes, summary };有错抛 Error。
74
- * 同步是因为 `presentationMeta` 必须同步返回 lossless JSON
22
+ export const inject = ['tools', 'connection']
23
+
24
+ /** 扩展名 MIME 推断表。 */
25
+ const MIME_BY_EXT = {
26
+ '.svg': 'image/svg+xml',
27
+ '.png': 'image/png',
28
+ '.jpg': 'image/jpeg',
29
+ '.jpeg': 'image/jpeg',
30
+ '.gif': 'image/gif',
31
+ '.webp': 'image/webp',
32
+ '.bmp': 'image/bmp',
33
+ '.ico': 'image/x-icon',
34
+ '.avif': 'image/avif',
35
+ }
36
+
37
+ /** args.label 取标题;空/缺省回退 fallback */
38
+ function labelOf(args, fallback) {
39
+ return typeof args === 'object' && args !== null && typeof args.label === 'string' && args.label !== ''
40
+ ? args.label
41
+ : fallback
42
+ }
43
+
44
+ // ── 图片预览 ────────────────────────────────────────────────────────────────
45
+
46
+ /** 从路径扩得 MIME;未知回退 octet-stream。 */
47
+ function mimeOf(path) {
48
+ const dot = path.lastIndexOf('.')
49
+ if (dot < 0) return 'application/octet-stream'
50
+ return MIME_BY_EXT[path.slice(dot).toLowerCase()] ?? 'application/octet-stream'
51
+ }
52
+
53
+ /** 提取内联 SVG 的 width/height(viewBox 或 attr),用于摘要。 */
54
+ function svgDimension(svg) {
55
+ const w = /width="([0-9.]+)"/.exec(svg)
56
+ const h = /height="([0-9.]+)"/.exec(svg)
57
+ const vb = /viewBox="[^"]*?([0-9.]+)[ ,]+([0-9.]+)[ ,]+([0-9.]+)[ ,]+([0-9.]+)"/.exec(svg)
58
+ if (w && h) return `${w[1]}×${h[1]}`
59
+ if (vb) return `${vb[3]}×${vb[4]}`
60
+ return 'unknown'
61
+ }
62
+
63
+ /** 模型可读的单行摘要。 */
64
+ function imageSummary(mime) {
65
+ return mime === 'image/svg+xml' ? 'SVG 矢量图' : mime.replace('image/', '').toUpperCase() + ' 位图'
66
+ }
67
+
68
+ /**
69
+ * 构建一次预览结果,返回 { src, mime, label, bytes, summary };有错抛 Error
75
70
  */
76
- function buildPreviewSync(args) {
77
- if (typeof args !== 'object' || args === null) {
78
- throw new Error('preview_image 需要对象参数(带 path 或 content)。')
79
- }
80
- const label = labelOf(args, '预览')
81
-
82
- // 优先:磁盘文件路径。
83
- if (typeof args.path === 'string' && args.path !== '') {
84
- const abs = resolve(args.path)
71
+ async function buildPreview(args, exec) {
72
+ if (typeof args !== 'object' || args === null) {
73
+ throw new Error('preview_image 需要对象参数(带 path 或 content)。')
74
+ }
75
+ const label = labelOf(args, '预览')
76
+
77
+ // 优先:磁盘文件路径。
78
+ if (typeof args.path === 'string' && args.path !== '') {
79
+ const abs = resolve(exec?.agent?.session?.header?.cwd || process.cwd(), args.path)
85
80
  const mime = mimeOf(abs)
86
- const buf = readFileSync(abs)
87
- const st = statSync(abs)
88
- const b64 = buf.toString('base64')
89
- return {
90
- src: `data:${mime};base64,${b64}`,
91
- mime,
92
- label,
93
- bytes: st.size,
94
- summary: imageSummary(mime),
95
- }
96
- }
97
-
98
- // 备选:调用方直接给内容。
99
- if (typeof args.content === 'string' && args.content !== '') {
81
+ if (!Object.values(MIME_BY_EXT).includes(mime)) throw new Error('不支持此图片格式。')
82
+ const buf = await readBounded(abs, MAX_INPUT_BYTES, exec?.signal)
83
+ const b64 = buf.toString('base64')
84
+ return {
85
+ src: `data:${mime};base64,${b64}`,
86
+ mime,
87
+ label,
88
+ bytes: buf.length,
89
+ summary: imageSummary(mime),
90
+ }
91
+ }
92
+
93
+ // 备选:调用方直接给内容。
94
+ if (typeof args.content === 'string' && args.content !== '') {
100
95
  const mime = args.mime === 'image/svg+xml' ? 'image/svg+xml' : (typeof args.mime === 'string' ? args.mime : 'image/svg+xml')
96
+ if (!Object.values(MIME_BY_EXT).includes(mime)) throw new Error('不支持此图片 MIME。')
97
+ if (Buffer.byteLength(args.content, 'utf8') > MAX_INPUT_BYTES) throw new Error('内联图片超过 8 MB 预览上限。')
101
98
  if (mime === 'image/svg+xml') {
102
99
  const b64 = Buffer.from(args.content, 'utf8').toString('base64')
103
- return { src: `data:image/svg+xml;base64,${b64}`, mime, label, bytes: b64.length, summary: `inline SVG ${svgDimension(args.content)}` }
104
- }
105
- // 假定 content 已是 base64。
106
- return { src: `data:${mime};base64,${args.content}`, mime, label, bytes: args.content.length, summary: `inline ${mime}` }
107
- }
108
-
109
- throw new Error('preview_image 需要 path 或 content 参数。')
110
- }
111
-
112
- /**
113
- * 构建 preview_image 工具定义。注册进工具注册表后,模型可用它发起一次
114
- * “预览”:宿主读取图片并投递 data-URI 到 meta,浏览器端 toolview 渲染。
115
- */
116
- export function createPreviewTool() {
117
- return {
118
- name: 'preview_image',
119
- description:
120
- '在聊天里预览一张图片或 SVG——用浏览器原生 <img> 渲染,绕过聊天渲染器对图片的过滤。'
121
- + ' 传 path 读取磁盘文件,或传 content(内联 SVG 文本)直接预览。'
122
- + ' 图片字节只走 meta 投递到浏览器,不进入模型上下文;返回的是紧凑摘要。'
123
- + ' 生成/修改图片时先调用它给用户预览,等用户确认后再做真正的写入/修改。',
124
- parameters: {
125
- type: 'object',
126
- properties: {
127
- path: {
128
- type: 'string',
129
- description: '要预览的图片文件路径(.svg/.png/.jpg/.gif/.webp 等)。优先用绝对路径。',
130
- },
131
- content: {
132
- type: 'string',
133
- description: '备选:内联 SVG 文本(或 base64 内容)。与 path 二选一。',
134
- },
135
- mime: {
136
- type: 'string',
137
- description: '当用 content 且非 SVG 时,指定内容 MIME(如 image/png)。',
138
- },
139
- label: {
140
- type: 'string',
141
- description: '图片标题,显示在预览上方。',
142
- },
143
- },
144
- additionalProperties: false,
145
- },
146
- output: {
147
- schema: { type: 'string', description: '单行预览摘要,给模型看。' },
148
- render(_args, value) {
149
- return [{ type: 'text', text: String(value) }]
150
- },
151
- presentationMeta(args) {
152
- // 必须同步返回 lossless JSON(genui 的 render_ui 正是如此:从 args 直接推导)。
153
- try {
154
- const r = buildPreviewSync(args)
155
- return { src: r.src, mime: r.mime, label: r.label }
156
- } catch {
157
- return null
158
- }
159
- },
160
- },
161
- async execute(args) {
162
- try {
163
- const r = buildPreviewSync(args)
164
- return `已渲染预览「${r.label}」:${r.summary}(${r.bytes} B)。图片已显示在聊天中,等待用户确认后再做真正的修改。`
165
- } catch (error) {
166
- const detail = error instanceof Error ? error.message : String(error)
167
- return `preview_image 失败:${detail}`
168
- }
169
- },
170
- presentCall(args) {
171
- return { card: 'generic', title: `预览「${labelOf(args, '预览图片')}」`, kind: 'other' }
172
- },
173
- presentResult(args) {
174
- return { card: 'generic', title: `预览「${labelOf(args, '预览图片')}」` }
175
- },
176
- }
177
- }
178
-
179
- // ── CSV 预览 ────────────────────────────────────────────────────────────────
180
-
181
- /** 候选分隔符(嗅探顺序即平局优先级)。 */
182
- const CSV_DELIMITERS = [',', ';', '\t', '|']
183
- /** 单次预览允许读入的最大体积,超过直接拒绝,保护宿主进程。 */
184
- const CSV_MAX_INPUT_BYTES = 8 * 1024 * 1024
185
- const CSV_MAX_ROWS_DEFAULT = 50
186
- const CSV_MAX_ROWS_LIMIT = 500
187
- const CSV_MAX_COLS = 40
188
- const CSV_CELL_CAP = 200
189
-
190
- function fmtBytes(n) {
191
- return n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / (1024 * 1024)).toFixed(2)} MB`
192
- }
193
-
194
- /**
195
- * 分隔符嗅探:统计前 4 KB 内引号外各候选出现次数,取最多者;全 0 回退逗号。
196
- * 遍历顺序即平局优先级(, ; Tab |)。
197
- */
198
- function detectDelimiter(text) {
199
- const counts = new Map(CSV_DELIMITERS.map((d) => [d, 0]))
200
- let inQuotes = false
201
- const end = Math.min(text.length, 4096)
202
- for (let i = 0; i < end; i++) {
203
- const ch = text[i]
204
- if (ch === '"') inQuotes = !inQuotes
205
- else if (!inQuotes && counts.has(ch)) counts.set(ch, counts.get(ch) + 1)
206
- }
207
- let best = CSV_DELIMITERS[0]
208
- let bestN = -1
209
- for (const d of CSV_DELIMITERS) {
210
- const n = counts.get(d)
211
- if (n > bestN) { best = d; bestN = n }
212
- }
213
- return bestN > 0 ? best : ','
214
- }
215
-
216
- /**
217
- * RFC 4180 风格的有界 CSV 解析:引号字段、"" 转义、引号内换行、CRLF。
218
- * 只保留前 maxRows 行(有界内存),但完整统计总行数与最大列数,摘要可用。
219
- * 跳过空行;返回 { rows, totalRows, totalCols }。
220
- */
221
- function parseCsvBounded(text, delimiter, maxRows) {
222
- const rows = []
223
- let row = []
224
- let field = ''
225
- let inQuotes = false
226
- let totalRows = 0
227
- let totalCols = 0
228
- const endRow = () => {
229
- row.push(field)
230
- field = ''
231
- if (row.length === 1 && row[0] === '') { row = []; return } // 空行不计
232
- if (row.length > totalCols) totalCols = row.length
233
- totalRows++
234
- if (rows.length < maxRows) rows.push(row)
235
- row = []
236
- }
237
- for (let i = 0; i < text.length; i++) {
238
- const ch = text[i]
239
- if (inQuotes) {
240
- if (ch === '"') {
241
- if (text[i + 1] === '"') { field += '"'; i++ }
242
- else inQuotes = false
243
- } else {
244
- field += ch
245
- }
246
- } else if (ch === '"') {
247
- inQuotes = true
248
- } else if (ch === delimiter) {
249
- row.push(field)
250
- field = ''
251
- } else if (ch === '\n') {
252
- endRow()
253
- } else if (ch !== '\r') {
254
- field += ch
100
+ return { src: `data:image/svg+xml;base64,${b64}`, mime, label, bytes: Buffer.byteLength(args.content), summary: `inline SVG ${svgDimension(args.content)}` }
255
101
  }
256
- }
257
- if (field !== '' || row.length > 0) endRow()
258
- return { rows, totalRows, totalCols }
259
- }
260
-
261
- function clampMaxRows(v) {
262
- const n = Math.floor(Number(v))
263
- if (!Number.isFinite(n) || n < 1) return CSV_MAX_ROWS_DEFAULT
264
- return Math.min(n, CSV_MAX_ROWS_LIMIT)
265
- }
266
-
267
- /** 显式分隔符 → 生效值;null 表示走嗅探(.tsv 扩展名直接判 Tab)。 */
268
- function resolveDelimiter(args) {
269
- const d = args.delimiter
270
- if (typeof d === 'string' && CSV_DELIMITERS.includes(d)) return d
271
- if (typeof args.path === 'string' && args.path !== '' && args.path.toLowerCase().endsWith('.tsv')) return '\t'
272
- return null
273
- }
274
-
275
- function capCell(c) {
276
- return c.length > CSV_CELL_CAP ? c.slice(0, CSV_CELL_CAP) + '…' : c
277
- }
278
-
279
- /**
280
- * 同步构建 CSV 预览 meta,返回 { label, delimiter, rows, totalRows, totalCols,
281
- * rowsShown, colsShown };有错抛 Error。rows 含表头行,已被列数/单元格截断并
282
- * 补齐成规则矩形,浏览器可直接渲染。
283
- */
284
- function buildCsvPreviewSync(args) {
285
- if (typeof args !== 'object' || args === null) {
286
- throw new Error('preview_csv 需要对象参数(带 path 或 content)。')
287
- }
288
- const label = labelOf(args, 'CSV 预览')
289
-
290
- let text = ''
291
- if (typeof args.path === 'string' && args.path !== '') {
292
- const abs = resolve(args.path)
293
- const buf = readFileSync(abs)
294
- if (buf.length > CSV_MAX_INPUT_BYTES) {
295
- throw new Error(`文件 ${fmtBytes(buf.length)} 超过预览上限 ${fmtBytes(CSV_MAX_INPUT_BYTES)},请先截取再预览。`)
102
+ const b64 = args.content.replace(/\s/g, '')
103
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(b64) || !b64) {
104
+ throw new Error('图片 content 必须是有效的 base64。')
296
105
  }
297
- text = buf.toString('utf8')
298
- } else if (typeof args.content === 'string' && args.content !== '') {
299
- if (Buffer.byteLength(args.content, 'utf8') > CSV_MAX_INPUT_BYTES) {
300
- throw new Error(`内联内容超过预览上限 ${fmtBytes(CSV_MAX_INPUT_BYTES)},请先截取再预览。`)
301
- }
302
- text = args.content
303
- } else {
304
- throw new Error('preview_csv 需要 path 或 content 参数。')
305
- }
306
-
307
- if (text.charCodeAt(0) === 0xfeff) text = text.slice(1) // 剥 BOM
308
-
309
- const auto = resolveDelimiter(args)
310
- const delimiter = auto ?? detectDelimiter(text)
311
- const parsed = parseCsvBounded(text, delimiter, clampMaxRows(args.maxRows))
312
- if (parsed.totalRows === 0) throw new Error('CSV 内容为空,没有可预览的数据行。')
313
-
314
- const colsShown = Math.min(parsed.totalCols, CSV_MAX_COLS)
315
- const rows = parsed.rows.map((r) => {
316
- const view = r.slice(0, CSV_MAX_COLS).map(capCell)
317
- while (view.length < colsShown) view.push('')
318
- return view
319
- })
320
- return {
321
- label,
322
- delimiter,
323
- rows,
324
- totalRows: parsed.totalRows,
325
- totalCols: parsed.totalCols,
326
- rowsShown: rows.length,
327
- colsShown,
328
- }
329
- }
330
-
331
- /** 模型可读的行列摘要(含截断信息)。 */
332
- function csvSummaryText(r) {
333
- const d = r.delimiter === '\t' ? 'Tab' : r.delimiter
334
- let s = `CSV 表格 ${r.totalRows} 行 × ${r.totalCols} 列,分隔符 "${d}"`
335
- if (r.rowsShown < r.totalRows) s += `;显示前 ${r.rowsShown} 行`
336
- if (r.colsShown < r.totalCols) s += ` / 前 ${r.colsShown} 列`
337
- return s + '。'
338
- }
339
-
340
- /**
341
- * 构建 preview_csv 工具定义。模型传 CSV 路径/文本发起一次「表格预览」:
342
- * 宿主有界解析后把表格投递到 meta,浏览器端 toolview 渲染原生 <table>。
343
- */
344
- export function createCsvTool() {
345
- return {
346
- name: 'preview_csv',
347
- description:
348
- '在聊天里预览 CSV/TSV 表格——用浏览器原生 <table> 渲染,粘性表头、控件限高局部滚动。'
349
- + ' 传 path 读取磁盘文件(.csv/.tsv 等),或传 content(内联 CSV 文本)。'
350
- + ' 分隔符自动嗅探(, ; Tab |),可用 delimiter 指定;默认只投递前 '
351
- + `${CSV_MAX_ROWS_DEFAULT} 行(可用 maxRows 调大,上限 ${CSV_MAX_ROWS_LIMIT})。`
352
- + ' 表格数据只走 meta 投递到浏览器,不进入模型上下文;返回的是行列摘要。'
353
- + ' 生成/修改 CSV 时先调用它给用户预览,等用户确认后再做真正的写入/修改。',
354
- parameters: {
355
- type: 'object',
356
- properties: {
357
- path: {
358
- type: 'string',
359
- description: '要预览的 CSV/TSV 文件路径。优先用绝对路径。',
360
- },
361
- content: {
362
- type: 'string',
363
- description: '备选:内联 CSV 文本。与 path 二选一。',
364
- },
365
- delimiter: {
366
- type: 'string',
367
- enum: [...CSV_DELIMITERS],
368
- description: '字段分隔符;缺省时自动嗅探,.tsv 文件默认 Tab。',
369
- },
370
- maxRows: {
371
- type: 'number',
372
- description: `预览的最大行数(含表头行),默认 ${CSV_MAX_ROWS_DEFAULT},上限 ${CSV_MAX_ROWS_LIMIT}。`,
373
- },
374
- label: {
375
- type: 'string',
376
- description: '表格标题,显示在预览上方。',
377
- },
378
- },
379
- additionalProperties: false,
380
- },
381
- output: {
382
- schema: { type: 'string', description: '单行预览摘要,给模型看。' },
383
- render(_args, value) {
384
- return [{ type: 'text', text: String(value) }]
385
- },
386
- presentationMeta(args) {
387
- // 必须同步返回 lossless JSON;解析全程同步。
388
- try {
389
- return buildCsvPreviewSync(args)
390
- } catch {
391
- return null
392
- }
393
- },
394
- },
395
- async execute(args) {
396
- try {
397
- const r = buildCsvPreviewSync(args)
398
- return `已渲染 CSV 预览「${r.label}」:${csvSummaryText(r)}表格已显示在聊天中,等待用户确认后再做真正的修改。`
399
- } catch (error) {
400
- const detail = error instanceof Error ? error.message : String(error)
401
- return `preview_csv 失败:${detail}`
402
- }
403
- },
404
- presentCall(args) {
405
- return { card: 'generic', title: `CSV 预览「${labelOf(args, 'CSV 表格')}」`, kind: 'other' }
106
+ return { src: `data:${mime};base64,${b64}`, mime, label, bytes: Buffer.from(b64, 'base64').length, summary: `inline ${mime}` }
107
+ }
108
+
109
+ throw new Error('preview_image 需要 path 或 content 参数。')
110
+ }
111
+
112
+ /**
113
+ * 构建 preview_image 工具定义。注册进工具注册表后,模型可用它发起一次
114
+ * “预览”:宿主读取图片并保存快照,浏览器端 toolview 按调用 ID 读取。
115
+ */
116
+ export function createPreviewTool(store = new PreviewStore()) {
117
+ return {
118
+ name: 'preview_image',
119
+ description:
120
+ '在聊天里预览一张图片或 SVG——用浏览器原生 <img> 渲染,绕过聊天渲染器对图片的过滤。'
121
+ + ' path 读取磁盘文件,或传 content(内联 SVG 文本)直接预览。'
122
+ + ' 图片快照通过独立通道投递到浏览器,不进入模型上下文;返回的是紧凑摘要。'
123
+ + ' 生成/修改图片时先调用它给用户预览,等用户确认后再做真正的写入/修改。',
124
+ parameters: {
125
+ type: 'object',
126
+ properties: {
127
+ path: {
128
+ type: 'string',
129
+ description: '要预览的图片文件路径(.svg/.png/.jpg/.gif/.webp 等)。优先用绝对路径。',
130
+ },
131
+ content: {
132
+ type: 'string',
133
+ description: '备选:内联 SVG 文本(或 base64 内容)。与 path 二选一。',
134
+ },
135
+ mime: {
136
+ type: 'string',
137
+ description: '当用 content 且非 SVG 时,指定内容 MIME(如 image/png)。',
138
+ },
139
+ label: {
140
+ type: 'string',
141
+ description: '图片标题,显示在预览上方。',
142
+ },
143
+ },
144
+ additionalProperties: false,
145
+ },
146
+ output: {
147
+ schema: { type: 'string', description: '单行预览摘要,给模型看。' },
148
+ render(_args, value) {
149
+ return [{ type: 'text', text: String(value) }]
150
+ },
406
151
  },
407
- presentResult(args) {
408
- return { card: 'generic', title: `CSV 预览「${labelOf(args, 'CSV 表格')}」` }
152
+ async execute(args, exec) {
153
+ const identity = executionIdentity(exec, 'preview_image')
154
+ const r = await buildPreview(args, exec)
155
+ await store.put(identity, { src: r.src, mime: r.mime, label: r.label }, exec.signal)
156
+ return `预览数据已生成「${r.label}」:${r.summary}(${r.bytes} B)。请查看预览,等待用户确认后再做真正的修改。`
157
+ },
158
+ presentCall(args) {
159
+ return { card: 'generic', title: `预览「${labelOf(args, '预览图片')}」`, kind: 'other' }
160
+ },
161
+ presentResult(args) {
162
+ return { card: 'generic', title: `预览「${labelOf(args, '预览图片')}」` }
163
+ },
164
+ }
165
+ }
166
+
167
+ // ── CSV 预览 ────────────────────────────────────────────────────────────────
168
+
169
+ /** 候选分隔符(嗅探顺序即平局优先级)。 */
170
+ const CSV_DELIMITERS = [',', ';', '\t', '|']
171
+ /** 单次预览允许读入的最大体积,超过直接拒绝,保护宿主进程。 */
172
+ const CSV_MAX_INPUT_BYTES = 8 * 1024 * 1024
173
+ const CSV_MAX_ROWS_DEFAULT = 50
174
+ const CSV_MAX_ROWS_LIMIT = 500
175
+ const CSV_MAX_COLS = 40
176
+ const CSV_CELL_CAP = 200
177
+
178
+ function fmtBytes(n) {
179
+ return n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / (1024 * 1024)).toFixed(2)} MB`
180
+ }
181
+
182
+ /**
183
+ * 分隔符嗅探:统计前 4 KB 内引号外各候选出现次数,取最多者;全 0 回退逗号。
184
+ * 遍历顺序即平局优先级(, ; Tab |)。
185
+ */
186
+ function detectDelimiter(text) {
187
+ const counts = new Map(CSV_DELIMITERS.map((d) => [d, 0]))
188
+ let inQuotes = false
189
+ const end = Math.min(text.length, 4096)
190
+ for (let i = 0; i < end; i++) {
191
+ const ch = text[i]
192
+ if (ch === '"') inQuotes = !inQuotes
193
+ else if (!inQuotes && counts.has(ch)) counts.set(ch, counts.get(ch) + 1)
194
+ }
195
+ let best = CSV_DELIMITERS[0]
196
+ let bestN = -1
197
+ for (const d of CSV_DELIMITERS) {
198
+ const n = counts.get(d)
199
+ if (n > bestN) { best = d; bestN = n }
200
+ }
201
+ return bestN > 0 ? best : ','
202
+ }
203
+
204
+ /**
205
+ * RFC 4180 风格的有界 CSV 解析:引号字段、"" 转义、引号内换行、CRLF。
206
+ * 只保留前 maxRows 行(有界内存),但完整统计总行数与最大列数,摘要可用。
207
+ * 跳过空行;返回 { rows, totalRows, totalCols }。
208
+ */
209
+ function parseCsvBounded(text, delimiter, maxRows) {
210
+ const rows = []
211
+ let row = []
212
+ let field = ''
213
+ let inQuotes = false
214
+ let totalRows = 0
215
+ let totalCols = 0
216
+ const endRow = () => {
217
+ row.push(field)
218
+ field = ''
219
+ if (row.length === 1 && row[0] === '') { row = []; return } // 空行不计
220
+ if (row.length > totalCols) totalCols = row.length
221
+ totalRows++
222
+ if (rows.length < maxRows) rows.push(row)
223
+ row = []
224
+ }
225
+ for (let i = 0; i < text.length; i++) {
226
+ const ch = text[i]
227
+ if (inQuotes) {
228
+ if (ch === '"') {
229
+ if (text[i + 1] === '"') { field += '"'; i++ }
230
+ else inQuotes = false
231
+ } else {
232
+ field += ch
233
+ }
234
+ } else if (ch === '"') {
235
+ inQuotes = true
236
+ } else if (ch === delimiter) {
237
+ row.push(field)
238
+ field = ''
239
+ } else if (ch === '\n') {
240
+ endRow()
241
+ } else if (ch !== '\r') {
242
+ field += ch
243
+ }
244
+ }
245
+ if (field !== '' || row.length > 0) endRow()
246
+ return { rows, totalRows, totalCols }
247
+ }
248
+
249
+ function clampMaxRows(v) {
250
+ const n = Math.floor(Number(v))
251
+ if (!Number.isFinite(n) || n < 1) return CSV_MAX_ROWS_DEFAULT
252
+ return Math.min(n, CSV_MAX_ROWS_LIMIT)
253
+ }
254
+
255
+ /** 显式分隔符 → 生效值;null 表示走嗅探(.tsv 扩展名直接判 Tab)。 */
256
+ function resolveDelimiter(args) {
257
+ const d = args.delimiter
258
+ if (typeof d === 'string' && CSV_DELIMITERS.includes(d)) return d
259
+ if (typeof args.path === 'string' && args.path !== '' && args.path.toLowerCase().endsWith('.tsv')) return '\t'
260
+ return null
261
+ }
262
+
263
+ function capCell(c) {
264
+ return c.length > CSV_CELL_CAP ? c.slice(0, CSV_CELL_CAP) + '…' : c
265
+ }
266
+
267
+ /**
268
+ * 构建 CSV 预览快照,返回 { label, delimiter, rows, totalRows, totalCols,
269
+ * rowsShown, colsShown };有错抛 Error。rows 含表头行,已被列数/单元格截断并
270
+ * 补齐成规则矩形,浏览器可直接渲染。
271
+ */
272
+ async function buildCsvPreview(args, exec) {
273
+ if (typeof args !== 'object' || args === null) {
274
+ throw new Error('preview_csv 需要对象参数(带 path 或 content)。')
275
+ }
276
+ const label = labelOf(args, 'CSV 预览')
277
+
278
+ let text = ''
279
+ if (typeof args.path === 'string' && args.path !== '') {
280
+ const abs = resolve(exec?.agent?.session?.header?.cwd || process.cwd(), args.path)
281
+ const buf = await readBounded(abs, CSV_MAX_INPUT_BYTES, exec?.signal)
282
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(buf) }
283
+ catch { throw new Error('CSV 不是有效的 UTF-8 编码,请转换为 UTF-8 后重试。') }
284
+ } else if (typeof args.content === 'string' && args.content !== '') {
285
+ if (Buffer.byteLength(args.content, 'utf8') > CSV_MAX_INPUT_BYTES) {
286
+ throw new Error(`内联内容超过预览上限 ${fmtBytes(CSV_MAX_INPUT_BYTES)},请先截取再预览。`)
287
+ }
288
+ text = args.content
289
+ } else {
290
+ throw new Error('preview_csv 需要 path 或 content 参数。')
291
+ }
292
+
293
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1) // 剥 BOM
294
+
295
+ const auto = resolveDelimiter(args)
296
+ const delimiter = auto ?? detectDelimiter(text)
297
+ const parsed = parseCsvBounded(text, delimiter, clampMaxRows(args.maxRows))
298
+ if (parsed.totalRows === 0) throw new Error('CSV 内容为空,没有可预览的数据行。')
299
+
300
+ const colsShown = Math.min(parsed.totalCols, CSV_MAX_COLS)
301
+ const rows = parsed.rows.map((r) => {
302
+ const view = r.slice(0, CSV_MAX_COLS).map(capCell)
303
+ while (view.length < colsShown) view.push('')
304
+ return view
305
+ })
306
+ return {
307
+ label,
308
+ delimiter,
309
+ rows,
310
+ totalRows: parsed.totalRows,
311
+ totalCols: parsed.totalCols,
312
+ rowsShown: rows.length,
313
+ colsShown,
314
+ }
315
+ }
316
+
317
+ /** 模型可读的行列摘要(含截断信息)。 */
318
+ function csvSummaryText(r) {
319
+ const d = r.delimiter === '\t' ? 'Tab' : r.delimiter
320
+ let s = `CSV 表格 ${r.totalRows} 行 × ${r.totalCols} 列,分隔符 "${d}"`
321
+ if (r.rowsShown < r.totalRows) s += `;显示前 ${r.rowsShown} 行`
322
+ if (r.colsShown < r.totalCols) s += ` / 前 ${r.colsShown} 列`
323
+ return s + '。'
324
+ }
325
+
326
+ /**
327
+ * 构建 preview_csv 工具定义。模型传 CSV 路径/文本发起一次「表格预览」:
328
+ * 宿主有界解析后保存表格快照,浏览器端 toolview 渲染原生 <table>。
329
+ */
330
+ export function createCsvTool(store = new PreviewStore()) {
331
+ return {
332
+ name: 'preview_csv',
333
+ description:
334
+ '在聊天里预览 CSV/TSV 表格——用浏览器原生 <table> 渲染,粘性表头、控件限高局部滚动。'
335
+ + ' 传 path 读取磁盘文件(.csv/.tsv 等),或传 content(内联 CSV 文本)。'
336
+ + ' 分隔符自动嗅探(, ; Tab |),可用 delimiter 指定;默认只投递前 '
337
+ + `${CSV_MAX_ROWS_DEFAULT} 行(可用 maxRows 调大,上限 ${CSV_MAX_ROWS_LIMIT})。`
338
+ + ' 表格快照通过独立通道投递到浏览器,不进入模型上下文;返回的是行列摘要。'
339
+ + ' 生成/修改 CSV 时先调用它给用户预览,等用户确认后再做真正的写入/修改。',
340
+ parameters: {
341
+ type: 'object',
342
+ properties: {
343
+ path: {
344
+ type: 'string',
345
+ description: '要预览的 CSV/TSV 文件路径。优先用绝对路径。',
346
+ },
347
+ content: {
348
+ type: 'string',
349
+ description: '备选:内联 CSV 文本。与 path 二选一。',
350
+ },
351
+ delimiter: {
352
+ type: 'string',
353
+ enum: [...CSV_DELIMITERS],
354
+ description: '字段分隔符;缺省时自动嗅探,.tsv 文件默认 Tab。',
355
+ },
356
+ maxRows: {
357
+ type: 'number',
358
+ description: `预览的最大行数(含表头行),默认 ${CSV_MAX_ROWS_DEFAULT},上限 ${CSV_MAX_ROWS_LIMIT}。`,
359
+ },
360
+ label: {
361
+ type: 'string',
362
+ description: '表格标题,显示在预览上方。',
363
+ },
364
+ },
365
+ additionalProperties: false,
366
+ },
367
+ output: {
368
+ schema: { type: 'string', description: '单行预览摘要,给模型看。' },
369
+ render(_args, value) {
370
+ return [{ type: 'text', text: String(value) }]
371
+ },
409
372
  },
410
- }
411
- }
412
-
413
- /**
414
- * 注册工具。`tools` 服务可能晚于本插件绑定(启动顺序),因此既在 apply 时
415
- * 探测一次,也订阅 `internal/service`(cordis 在每次服务绑定时发出),确保
416
- * 一旦工具注册表出现就立即注册。
417
- */
373
+ async execute(args, exec) {
374
+ const identity = executionIdentity(exec, 'preview_csv')
375
+ const r = await buildCsvPreview(args, exec)
376
+ await store.put(identity, r, exec.signal)
377
+ return `CSV 预览数据已生成「${r.label}」:${csvSummaryText(r)}请查看预览,等待用户确认后再做真正的修改。`
378
+ },
379
+ presentCall(args) {
380
+ return { card: 'generic', title: `CSV 预览「${labelOf(args, 'CSV 表格')}」`, kind: 'other' }
381
+ },
382
+ presentResult(args) {
383
+ return { card: 'generic', title: `CSV 预览「${labelOf(args, 'CSV 表格')}」` }
384
+ },
385
+ }
386
+ }
387
+
388
+ /** Service declarations park this plugin until dependencies are active. */
418
389
  export function apply(ctx) {
419
- let registered = false
420
- const tryRegister = (value) => {
421
- if (registered) return
422
- const tools = value ?? ctx.reflect.get('tools', false)
423
- if (tools === undefined) return
424
- tools.register(createPreviewTool())
425
- tools.register(createCsvTool())
426
- registered = true
427
- }
428
- tryRegister(undefined)
429
- if (typeof ctx.on === 'function') {
430
- ctx.on('internal/service', (name, value) => {
431
- if (name === 'tools') tryRegister(value)
432
- })
433
- }
390
+ const store = new PreviewStore()
391
+ ctx.effect(() => ctx.connection.fetch.register(createPreviewRoute(store)), 'preview: exact RPC route')
392
+ ctx.tools.register(createPreviewTool(store))
393
+ ctx.tools.register(createCsvTool(store))
434
394
  }