file-toolkit-mcp 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 File Toolkit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # file-toolkit-mcp
2
+
3
+ 为 WorkBuddy 连接器「**文件工具箱**」提供能力的 MCP 服务,包含 15 个确定性的文件处理工具。
4
+
5
+ **完全在本机运行**:不联网、不上传文件、不需要 API Key、不依赖任何付费服务。
6
+
7
+ ## 工具清单
8
+
9
+ | 工具 | 作用 |
10
+ |---|---|
11
+ | `image_compress` | 压缩 jpg / png / webp,可按目标体积自动逼近画质 |
12
+ | `image_convert` | jpg / png / webp 互转,png 透明背景转 jpg 时自动铺白底 |
13
+ | `image_resize` | 按宽高或百分比缩放,支持只缩不放 |
14
+ | `pdf_split` | 逐页拆分 / 按页码提取 / 每 N 页一组 |
15
+ | `pdf_merge` | 按指定顺序合并多个 PDF |
16
+ | `pdf_to_image` | PDF 页面渲染成 png / jpg,可指定页码和 dpi |
17
+ | `image_to_pdf` | 一张或多张图片合成 PDF |
18
+ | `json_format` | JSON 美化 / 压缩 / 校验(报错会指出行列位置) |
19
+ | `csv_to_json` | CSV / TSV 转 JSON,自动识别 UTF-8 / GBK |
20
+ | `json_to_csv` | JSON 转 CSV,输出带 BOM(Excel 打开中文不乱码) |
21
+ | `markdown_to_html` | Markdown 转 HTML,可选完整网页或片段 |
22
+ | `html_to_markdown` | HTML 转 Markdown,保留表格与代码块 |
23
+ | `batch_rename` | 批量重命名,默认只预览,支持序号模板 |
24
+ | `rename_undo` | 撤销上一次批量重命名 |
25
+ | `file_info` | 文件大小、类型、时间、图片尺寸、PDF 页数 |
26
+
27
+ ## 使用方式
28
+
29
+ 由 WorkBuddy 连接器自动拉起,用户无需手动执行:
30
+
31
+ ```bash
32
+ npx -y file-toolkit-mcp@1.0.0
33
+ ```
34
+
35
+ 也可以在任何支持 MCP 的客户端里手动配置(stdio 传输):
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "file-toolkit": {
41
+ "type": "stdio",
42
+ "command": "npx",
43
+ "args": ["-y", "file-toolkit-mcp@1.0.0"]
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ## 输出规则
50
+
51
+ 所有工具遵循同一套输出策略:
52
+
53
+ - 结果写到「第一个输入文件所在目录」下的 `file-toolkit-output/` 文件夹
54
+ - **绝不覆盖原文件**,同名时自动加序号
55
+ - 可以传文件夹,会自动收集里面的文件批量处理
56
+ - 批量处理时单个文件失败不会中断整批,结果里分别列出成功与失败
57
+
58
+ ## 隐私
59
+
60
+ 所有处理都在调用方的机器上完成。本服务不发起任何网络请求,
61
+ 不读取处理目标以外的文件,也不写入 `file-toolkit-output/` 以外的位置
62
+ (批量重命名会在原地改名,但会写一份可回滚的记录)。
63
+
64
+ ## 依赖
65
+
66
+ 运行时由 WorkBuddy 托管的 Node.js(>= 18.17)提供,用户端无需预装环境。
67
+
68
+ - `sharp`(Apache-2.0)图片编解码
69
+ - `pdf-lib`(MIT)PDF 读写
70
+ - `pdfjs-dist`(Apache-2.0)+ `@napi-rs/canvas`(MIT)PDF 光栅化
71
+ - `marked`(MIT)、`turndown`(MIT)文本格式转换
72
+ - `iconv-lite`(MIT)中文编码识别
73
+ - `@modelcontextprotocol/sdk`(MIT)
74
+
75
+ 不含任何 AGPL / GPL 组件。
76
+
77
+ ## 许可证
78
+
79
+ MIT
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * File Toolkit MCP Server 的可执行入口。
4
+ * WorkBuddy 通过 `npx -y file-toolkit-mcp` 拉起的就是这个文件。
5
+ */
6
+ import '../src/server.js';
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "file-toolkit-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Local file toolkit MCP server for WorkBuddy. Compress, convert and resize images; split, merge and rasterize PDFs; convert CSV/JSON/Markdown; batch rename and inspect files. 100% local processing, no uploads, no paid APIs.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=18.17.0"
9
+ },
10
+ "bin": {
11
+ "file-toolkit-mcp": "bin/file-toolkit-mcp.js"
12
+ },
13
+ "main": "src/server.js",
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "README.md"
18
+ ],
19
+ "keywords": [
20
+ "mcp",
21
+ "workbuddy",
22
+ "file",
23
+ "image",
24
+ "pdf",
25
+ "convert",
26
+ "compress",
27
+ "local"
28
+ ],
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.31.0",
31
+ "@napi-rs/canvas": "^1.0.9",
32
+ "iconv-lite": "^0.7.3",
33
+ "marked": "^18.0.14",
34
+ "pdf-lib": "^1.17.1",
35
+ "pdfjs-dist": "^6.3.289",
36
+ "sharp": "^0.35.5",
37
+ "turndown": "^7.2.4",
38
+ "zod": "^4.6.5"
39
+ }
40
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 逐文件批处理:单个文件失败不影响其余文件。
3
+ * 「批量处理时一个坏文件不应该毁掉整批任务」是这个连接器的核心价值之一。
4
+ */
5
+
6
+ import { ToolError, describeError } from './errors.js';
7
+
8
+ /**
9
+ * @param {string[]} files
10
+ * @param {(file: string, index: number) => Promise<object>} worker
11
+ */
12
+ export async function runBatch(files, worker, { onProgress = null } = {}) {
13
+ const results = [];
14
+ for (let i = 0; i < files.length; i += 1) {
15
+ const file = files[i];
16
+ try {
17
+ const value = await worker(file, i);
18
+ results.push({ ok: true, file, ...value });
19
+ } catch (error) {
20
+ results.push({ ok: false, file, error });
21
+ }
22
+ if (onProgress) onProgress(i + 1, files.length);
23
+ }
24
+
25
+ assertNotAllFailed(results);
26
+ return results;
27
+ }
28
+
29
+ /**
30
+ * 一个文件都没成功时,把错误抛出去。
31
+ *
32
+ * 为什么必须这样做:批量处理时我们把「单文件失败」降级成表格里的一行,
33
+ * 但如果全部失败(通常是路径写错、页码越界、格式不支持这类用户侧问题),
34
+ * 继续返回一个「看起来正常」的结果,会让模型误以为任务已经完成了。
35
+ * 这种情况必须明确报错,模型才能自我纠正。
36
+ */
37
+ function assertNotAllFailed(results) {
38
+ if (results.length === 0) return;
39
+ if (results.some((r) => r.ok)) return;
40
+
41
+ const first = results[0].error;
42
+
43
+ // 用户侧的问题(参数不合法、页码越界等)本身就是 ToolError,原样抛出,保留修复建议
44
+ if (first instanceof ToolError) throw first;
45
+
46
+ const message = describeError(first, {});
47
+ throw new ToolError(
48
+ results.length === 1
49
+ ? message
50
+ : `${results.length} 个文件全部处理失败。第一个错误:${message}`,
51
+ { hint: '请检查文件路径、文件格式和参数是否正确。' },
52
+ );
53
+ }
package/src/lib/csv.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * CSV 编解码(RFC 4180),针对中文场景做了处理:
3
+ * - 自动识别并剥掉 UTF-8 BOM
4
+ * - 写出时默认带 BOM,保证 Excel 直接双击打开中文不乱码
5
+ * - 正确处理引号包裹的逗号、换行、双引号转义
6
+ * - CRLF / LF 两种换行都能读
7
+ *
8
+ * 刻意不引入第三方 CSV 库:这里的行为需要完全可预期,
9
+ * 而且少一个依赖就少一份 npx 首次安装的体积和失败概率。
10
+ */
11
+
12
+ const BOM = '\uFEFF';
13
+
14
+ /**
15
+ * 解析 CSV 文本。
16
+ * @param {string} text
17
+ * @param {{ delimiter?: string }} [options]
18
+ * @returns {string[][]} 二维数组,行 × 列
19
+ */
20
+ export function parseCsv(text, options = {}) {
21
+ const { delimiter = ',' } = options;
22
+ let source = String(text ?? '');
23
+ if (source.charCodeAt(0) === 0xfeff) source = source.slice(1);
24
+
25
+ const rows = [];
26
+ let row = [];
27
+ let field = '';
28
+ let inQuotes = false;
29
+ let i = 0;
30
+
31
+ while (i < source.length) {
32
+ const ch = source[i];
33
+
34
+ if (inQuotes) {
35
+ if (ch === '"') {
36
+ if (source[i + 1] === '"') {
37
+ field += '"';
38
+ i += 2;
39
+ continue;
40
+ }
41
+ inQuotes = false;
42
+ i += 1;
43
+ continue;
44
+ }
45
+ field += ch;
46
+ i += 1;
47
+ continue;
48
+ }
49
+
50
+ if (ch === '"') {
51
+ inQuotes = true;
52
+ i += 1;
53
+ continue;
54
+ }
55
+ if (ch === delimiter) {
56
+ row.push(field);
57
+ field = '';
58
+ i += 1;
59
+ continue;
60
+ }
61
+ if (ch === '\r') {
62
+ if (source[i + 1] === '\n') i += 1;
63
+ row.push(field);
64
+ rows.push(row);
65
+ row = [];
66
+ field = '';
67
+ i += 1;
68
+ continue;
69
+ }
70
+ if (ch === '\n') {
71
+ row.push(field);
72
+ rows.push(row);
73
+ row = [];
74
+ field = '';
75
+ i += 1;
76
+ continue;
77
+ }
78
+ field += ch;
79
+ i += 1;
80
+ }
81
+
82
+ if (field !== '' || row.length > 0) {
83
+ row.push(field);
84
+ rows.push(row);
85
+ }
86
+
87
+ return rows;
88
+ }
89
+
90
+ /**
91
+ * 把二维数组还原成 CSV 文本。
92
+ * @param {string[]} headers
93
+ * @param {Array<Array<unknown>>} rows
94
+ * @param {{ delimiter?: string, bom?: boolean, eol?: string }} [options]
95
+ */
96
+ export function serializeCsv(headers, rows, options = {}) {
97
+ const { delimiter = ',', bom = true, eol = '\n' } = options;
98
+
99
+ const escape = (value) => {
100
+ if (value === null || value === undefined) return '';
101
+ const s = typeof value === 'object' ? JSON.stringify(value) : String(value);
102
+ if (s.includes('"') || s.includes(delimiter) || s.includes('\n') || s.includes('\r')) {
103
+ return `"${s.replace(/"/g, '""')}"`;
104
+ }
105
+ return s;
106
+ };
107
+
108
+ const lines = [];
109
+ if (Array.isArray(headers) && headers.length > 0) {
110
+ lines.push(headers.map(escape).join(delimiter));
111
+ }
112
+ for (const row of rows) {
113
+ lines.push(row.map(escape).join(delimiter));
114
+ }
115
+
116
+ return (bom ? BOM : '') + lines.join(eol) + eol;
117
+ }
118
+
119
+ /**
120
+ * CSV 行 -> 对象数组。
121
+ * @param {string[][]} rows
122
+ * @param {{ headers?: string[] }} [options] 不传则把第一行当表头
123
+ */
124
+ export function rowsToRecords(rows, options = {}) {
125
+ const meaningful = rows.filter(
126
+ (r) => !(r.length === 1 && String(r[0] ?? '').trim() === ''),
127
+ );
128
+ if (meaningful.length === 0) return { headers: [], records: [] };
129
+
130
+ const explicit = options.headers;
131
+ const headers = explicit && explicit.length
132
+ ? explicit.map(String)
133
+ : meaningful[0].map((h, idx) => {
134
+ const name = String(h ?? '').trim();
135
+ return name === '' ? `column_${idx + 1}` : name;
136
+ });
137
+
138
+ const body = explicit && explicit.length ? meaningful : meaningful.slice(1);
139
+ const records = body.map((row) => {
140
+ const obj = {};
141
+ headers.forEach((key, idx) => {
142
+ obj[key] = row[idx] === undefined ? '' : String(row[idx]);
143
+ });
144
+ return obj;
145
+ });
146
+
147
+ return { headers, records };
148
+ }
149
+
150
+ /**
151
+ * 对象数组 -> 二维数组(按表头顺序取列,缺失的补空字符串)。
152
+ */
153
+ export function recordsToRows(records, headers) {
154
+ return records.map((rec) =>
155
+ headers.map((key) => {
156
+ const v = rec?.[key];
157
+ return v === null || v === undefined ? '' : v;
158
+ }),
159
+ );
160
+ }
161
+
162
+ /**
163
+ * 从对象数组里收集表头:保持出现顺序,并补齐各对象键的并集。
164
+ */
165
+ export function collectHeaders(records) {
166
+ const headers = [];
167
+ const seen = new Set();
168
+ for (const rec of records) {
169
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) continue;
170
+ for (const key of Object.keys(rec)) {
171
+ if (!seen.has(key)) {
172
+ seen.add(key);
173
+ headers.push(key);
174
+ }
175
+ }
176
+ }
177
+ return headers;
178
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 数据格式处理:JSON 美化 / 压缩 / 校验。
3
+ *
4
+ * 刻意保留键的原始顺序(JSON.parse 本身保序),
5
+ * 并且不做任何数值精度上的自作主张(大整数用 JSON.rawJSON 思路规避不了,
6
+ * 所以这里只保证不做多余转换,原样输出)。
7
+ */
8
+
9
+ /**
10
+ * @param {string} text 原始 JSON 文本
11
+ * @param {{ mode?: 'pretty'|'minify'|'validate', indent?: number }} [options]
12
+ */
13
+ export function processJson(text, options = {}) {
14
+ const { mode = 'pretty', indent = 2 } = options;
15
+ const source = String(text ?? '').replace(/^\uFEFF/, '').trim();
16
+
17
+ if (source === '') {
18
+ return { ok: false, error: '内容是空的,没有可以解析的 JSON' };
19
+ }
20
+
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(source);
24
+ } catch (error) {
25
+ return { ok: false, error: describeJsonError(error, source) };
26
+ }
27
+
28
+ const stats = describeShape(parsed);
29
+
30
+ if (mode === 'validate') {
31
+ return { ok: true, parsed, stats, output: null };
32
+ }
33
+
34
+ if (mode === 'minify') {
35
+ return { ok: true, parsed, stats, output: JSON.stringify(parsed) };
36
+ }
37
+
38
+ const space = Number.isFinite(indent) ? Math.min(8, Math.max(0, Number(indent))) : 2;
39
+ return { ok: true, parsed, stats, output: JSON.stringify(parsed, null, space) };
40
+ }
41
+
42
+ /**
43
+ * 把 JSON.parse 的报错翻译成带行列号的中文提示。
44
+ */
45
+ function describeJsonError(error, source) {
46
+ const message = String(error?.message ?? error);
47
+ const positionMatch = message.match(/position\s+(\d+)/i);
48
+
49
+ if (positionMatch) {
50
+ const position = Number(positionMatch[1]);
51
+ const before = source.slice(0, position);
52
+ const line = before.split('\n').length;
53
+ const column = position - before.lastIndexOf('\n');
54
+ return `JSON 有语法错误:第 ${line} 行第 ${column} 个字符附近(${message})`;
55
+ }
56
+
57
+ return `JSON 有语法错误:${message}`;
58
+ }
59
+
60
+ /** 描述 JSON 结构,方便用户确认"解析对了没有" */
61
+ export function describeShape(value) {
62
+ if (Array.isArray(value)) {
63
+ return {
64
+ type: '数组',
65
+ length: value.length,
66
+ firstItemType: value.length > 0 ? typeof value[0] : null,
67
+ };
68
+ }
69
+ if (value === null) return { type: 'null' };
70
+ if (typeof value === 'object') {
71
+ return {
72
+ type: '对象',
73
+ keys: Object.keys(value).length,
74
+ keyNames: Object.keys(value).slice(0, 20),
75
+ };
76
+ }
77
+ return { type: typeof value };
78
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 文本解码。
3
+ *
4
+ * 中文用户从 Excel / WPS 导出的 CSV 经常是 GBK 而不是 UTF-8,
5
+ * 直接按 UTF-8 读会整片乱码。这里按 BOM -> 严格 UTF-8 -> GBK 的顺序自动判断。
6
+ */
7
+
8
+ import iconv from 'iconv-lite';
9
+
10
+ /**
11
+ * @param {Buffer} buffer
12
+ * @param {string|null} [explicitEncoding] 用户明确指定的编码,如 'utf-8'、'gbk'、'big5'
13
+ * @returns {{ text: string, encoding: string, guessed: boolean }}
14
+ */
15
+ export function decodeBuffer(buffer, explicitEncoding = null) {
16
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
17
+
18
+ if (explicitEncoding) {
19
+ const normalized = String(explicitEncoding).toLowerCase().replace(/[-_]/g, '');
20
+ return {
21
+ text: iconv.decode(buf, normalized),
22
+ encoding: explicitEncoding,
23
+ guessed: false,
24
+ };
25
+ }
26
+
27
+ // 1) BOM 优先
28
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
29
+ return { text: buf.subarray(3).toString('utf8'), encoding: 'utf-8 (BOM)', guessed: true };
30
+ }
31
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
32
+ return { text: iconv.decode(buf, 'utf16le'), encoding: 'utf-16le', guessed: true };
33
+ }
34
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
35
+ return { text: iconv.decode(buf, 'utf16be'), encoding: 'utf-16be', guessed: true };
36
+ }
37
+
38
+ // 2) 严格 UTF-8 校验:能过就是 UTF-8
39
+ try {
40
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(buf);
41
+ return { text, encoding: 'utf-8', guessed: true };
42
+ } catch {
43
+ // 继续往下猜
44
+ }
45
+
46
+ // 3) 退到 GBK —— 中文 Windows 环境导出 CSV 的默认编码
47
+ return { text: iconv.decode(buf, 'gbk'), encoding: 'gbk', guessed: true };
48
+ }
49
+
50
+ /**
51
+ * 猜测 CSV 分隔符:看第一行里哪个候选出现得最多。
52
+ */
53
+ export function detectDelimiter(text) {
54
+ const firstLine = String(text ?? '').split(/\r?\n/)[0] ?? '';
55
+ const candidates = [',', '\t', ';', '|'];
56
+ let best = ',';
57
+ let bestCount = 0;
58
+ for (const candidate of candidates) {
59
+ const count = firstLine.split(candidate).length - 1;
60
+ if (count > bestCount) {
61
+ best = candidate;
62
+ bestCount = count;
63
+ }
64
+ }
65
+ return best;
66
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 统一错误处理。
3
+ *
4
+ * 核心原则:绝不把底层异常(Traceback、libvips 报错、spawn ENOENT……)直接抛给用户,
5
+ * 一律转换成可读的自然语言说明,并尽量给出下一步该怎么做。
6
+ */
7
+
8
+ export class ToolError extends Error {
9
+ constructor(message, { hint = '', cause = null } = {}) {
10
+ super(message);
11
+ this.name = 'ToolError';
12
+ this.hint = hint;
13
+ this.cause = cause;
14
+ }
15
+ }
16
+
17
+ const SYSTEM_MESSAGES = {
18
+ ENOENT: '找不到这个文件或目录,请确认路径是否正确(路径里有空格或中文时不需要额外加引号)',
19
+ EACCES: '没有权限访问这个文件,请检查文件权限,或换一个可写的目录再试',
20
+ EPERM: '系统拒绝了这个操作,文件可能是只读的,或所在目录受保护',
21
+ EISDIR: '这是一个目录,但这个操作需要的是文件',
22
+ ENOTDIR: '路径中有一段不是目录,请检查路径是否写错',
23
+ ENOSPC: '磁盘空间不足,请清理一些空间后重试',
24
+ EBUSY: '文件正被其他程序占用,请先关闭该程序再试',
25
+ EMFILE: '同时打开的文件过多,请减少一次处理的文件数量',
26
+ EROFS: '目标位置是只读的,无法写入',
27
+ EEXIST: '目标文件已存在,为避免覆盖已中止操作',
28
+ };
29
+
30
+ const PATTERN_MESSAGES = [
31
+ [/unsupported image format|Input file contains unsupported|VipsForeignLoad/i,
32
+ '这个文件不是能识别的图片格式,或者图片已损坏'],
33
+ [/invalid pdf structure|failed to parse pdf|no pdf header|InvalidPDFException/i,
34
+ '无法读取这个 PDF,文件可能已损坏,或者它并不是一个标准 PDF'],
35
+ [/password|encrypted|EncryptedPDF/i,
36
+ '这个 PDF 带有密码保护,请先解除密码再处理'],
37
+ [/premature end|corrupt|truncated|unexpected end/i,
38
+ '文件内容不完整,可能在传输或保存过程中损坏了'],
39
+ [/unexpected token|json\.parse|Unexpected non-whitespace/i,
40
+ 'JSON 内容有语法错误,无法解析'],
41
+ [/out of memory|heap out of memory|allocation failed/i,
42
+ '内存不足,请减少一次处理的文件数量,或降低输出分辨率'],
43
+ [/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|fetch failed/i,
44
+ '网络请求失败(本工具不依赖网络,通常说明环境异常)'],
45
+ ];
46
+
47
+ /**
48
+ * 把任意异常描述成一句用户能看懂的话。
49
+ *
50
+ * @param {unknown} error 原始异常
51
+ * @param {{ action?: string, target?: string }} [context] 正在做的事情和目标,用于补全句子
52
+ * @returns {string}
53
+ */
54
+ export function describeError(error, context = {}) {
55
+ const { action, target } = context;
56
+ const prefix = action ? `${action}失败` : '操作失败';
57
+ const where = target ? `(${target})` : '';
58
+
59
+ if (error instanceof ToolError) {
60
+ const hint = error.hint ? ` ${error.hint}` : '';
61
+ return `${prefix}${where}:${error.message}${hint}`;
62
+ }
63
+
64
+ const raw = error && typeof error === 'object' && 'message' in error
65
+ ? String(error.message)
66
+ : String(error ?? '未知错误');
67
+
68
+ // 1) 系统错误码优先:Node 的异常对象上带 code
69
+ const code = error && typeof error === 'object' ? error.code : undefined;
70
+ if (code && SYSTEM_MESSAGES[code]) {
71
+ return `${prefix}${where}:${SYSTEM_MESSAGES[code]}`;
72
+ }
73
+
74
+ // 2) 第三方库的报错按特征匹配
75
+ for (const [pattern, message] of PATTERN_MESSAGES) {
76
+ if (pattern.test(raw)) {
77
+ return `${prefix}${where}:${message}`;
78
+ }
79
+ }
80
+
81
+ // 3) 兜底:截断原始信息,避免把大段堆栈甩给用户
82
+ const brief = raw.split('\n')[0].slice(0, 160);
83
+ return `${prefix}${where}:${brief}`;
84
+ }
85
+
86
+ /**
87
+ * 校验类错误。参数不合法时用它,比通用异常更容易生成清晰的提示。
88
+ */
89
+ export function invalid(message, hint = '') {
90
+ return new ToolError(message, { hint });
91
+ }
92
+
93
+ /**
94
+ * 断言辅助:条件不满足就抛出参数错误。
95
+ */
96
+ export function assertThat(condition, message, hint = '') {
97
+ if (!condition) throw invalid(message, hint);
98
+ }