@jetecho/dsh-csv-and-image-preview 0.1.0 → 0.1.2
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/Agent.md +41 -39
- package/LICENSE +21 -21
- package/README.en.md +79 -73
- package/README.md +79 -73
- package/cordis.patch.yml +6 -6
- package/lib/client.js +190 -83
- package/lib/index.js +434 -191
- package/lib/types/index.d.ts +54 -32
- package/package.json +67 -65
package/lib/index.js
CHANGED
|
@@ -1,191 +1,434 @@
|
|
|
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.
|
|
8
|
-
*
|
|
9
|
-
* 3. `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
'.
|
|
31
|
-
'.
|
|
32
|
-
'.
|
|
33
|
-
'.
|
|
34
|
-
'.
|
|
35
|
-
'.
|
|
36
|
-
'.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return '
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
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'
|
|
24
|
+
import { resolve } from 'node:path'
|
|
25
|
+
|
|
26
|
+
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。
|
|
75
|
+
*/
|
|
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)
|
|
85
|
+
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 !== '') {
|
|
100
|
+
const mime = args.mime === 'image/svg+xml' ? 'image/svg+xml' : (typeof args.mime === 'string' ? args.mime : 'image/svg+xml')
|
|
101
|
+
if (mime === 'image/svg+xml') {
|
|
102
|
+
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
|
|
255
|
+
}
|
|
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)},请先截取再预览。`)
|
|
296
|
+
}
|
|
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' }
|
|
406
|
+
},
|
|
407
|
+
presentResult(args) {
|
|
408
|
+
return { card: 'generic', title: `CSV 预览「${labelOf(args, 'CSV 表格')}」` }
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* 注册工具。`tools` 服务可能晚于本插件绑定(启动顺序),因此既在 apply 时
|
|
415
|
+
* 探测一次,也订阅 `internal/service`(cordis 在每次服务绑定时发出),确保
|
|
416
|
+
* 一旦工具注册表出现就立即注册。
|
|
417
|
+
*/
|
|
418
|
+
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
|
+
}
|
|
434
|
+
}
|