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 +21 -0
- package/README.md +79 -0
- package/bin/file-toolkit-mcp.js +6 -0
- package/package.json +40 -0
- package/src/lib/batch.js +53 -0
- package/src/lib/csv.js +178 -0
- package/src/lib/dataops.js +78 -0
- package/src/lib/encoding.js +66 -0
- package/src/lib/errors.js +98 -0
- package/src/lib/image.js +331 -0
- package/src/lib/inspect.js +108 -0
- package/src/lib/paths.js +231 -0
- package/src/lib/pdf.js +328 -0
- package/src/lib/registry.js +53 -0
- package/src/lib/rename.js +285 -0
- package/src/lib/result.js +86 -0
- package/src/lib/text.js +80 -0
- package/src/server.js +60 -0
- package/src/tools/data-tools.js +497 -0
- package/src/tools/file-tools.js +348 -0
- package/src/tools/image-tools.js +294 -0
- package/src/tools/pdf-tools.js +335 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { defineTool } from '../lib/registry.js';
|
|
5
|
+
import { runBatch } from '../lib/batch.js';
|
|
6
|
+
import { expandInputs, resolveOutputDir, displayPath, OUTPUT_DIR_NAME } from '../lib/paths.js';
|
|
7
|
+
import {
|
|
8
|
+
planRename,
|
|
9
|
+
executeRename,
|
|
10
|
+
findLatestLog,
|
|
11
|
+
undoRename,
|
|
12
|
+
RENAME_LOG_PREFIX,
|
|
13
|
+
} from '../lib/rename.js';
|
|
14
|
+
import { inspectFile } from '../lib/inspect.js';
|
|
15
|
+
import { buildResult, mdTable, summarize, formatBytes } from '../lib/result.js';
|
|
16
|
+
import { describeError, invalid } from '../lib/errors.js';
|
|
17
|
+
|
|
18
|
+
const MAX_INLINE_ROWS = 40;
|
|
19
|
+
|
|
20
|
+
export function registerFileTools(server) {
|
|
21
|
+
defineTool(server, {
|
|
22
|
+
name: 'batch_rename',
|
|
23
|
+
title: '批量重命名',
|
|
24
|
+
action: '批量重命名',
|
|
25
|
+
description:
|
|
26
|
+
'按规则批量重命名文件。支持加前缀、加后缀、替换字符串、按序号编号(pattern 里用 {n} 表示序号,例如 "product-{n:3}" 生成 product-001)、改扩展名。重要:默认只预览不执行,用户确认预览结果后再用 confirm=true 真正改名。执行后可以随时用 rename_undo 回滚。',
|
|
27
|
+
inputSchema: {
|
|
28
|
+
input_files: z
|
|
29
|
+
.array(z.string())
|
|
30
|
+
.describe('要重命名的文件路径,可以传文件夹。传文件夹时配合 only_extensions 限定范围。'),
|
|
31
|
+
only_extensions: z
|
|
32
|
+
.array(z.string())
|
|
33
|
+
.optional()
|
|
34
|
+
.describe('只处理这些后缀的文件,例如 ["jpg","png"]。用户在文件夹语境下说"图片"时用这个限定。'),
|
|
35
|
+
pattern: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe(
|
|
39
|
+
'新的文件名模板。{n}=序号、{n:3}=补零到3位、{name}=原文件名(不含后缀)、{ext}=原后缀。例如"product-{n:3}"。不填则保留原文件名。',
|
|
40
|
+
),
|
|
41
|
+
prefix: z.string().optional().describe('在原文件名前面加的内容。'),
|
|
42
|
+
suffix: z.string().optional().describe('在原文件名后面、扩展名前面加的内容。'),
|
|
43
|
+
find: z.string().optional().describe('要被替换掉的字符串。'),
|
|
44
|
+
replace: z.string().optional().describe('替换成什么,配合 find 使用。默认替换为空(即删除)。'),
|
|
45
|
+
new_extension: z.string().optional().describe('把扩展名改成这个,例如 "jpg"(不带点)。'),
|
|
46
|
+
start_number: z.number().optional().describe('序号从几开始,默认 1。'),
|
|
47
|
+
confirm: z
|
|
48
|
+
.boolean()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe(
|
|
51
|
+
'是否真正执行。默认 false,只返回预览让用户确认。用户看过预览并同意之后,重新调用并传 true。',
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
handler: async (args) => {
|
|
55
|
+
const {
|
|
56
|
+
input_files,
|
|
57
|
+
only_extensions = null,
|
|
58
|
+
pattern = null,
|
|
59
|
+
prefix = '',
|
|
60
|
+
suffix = '',
|
|
61
|
+
find = null,
|
|
62
|
+
replace = '',
|
|
63
|
+
new_extension = null,
|
|
64
|
+
start_number = 1,
|
|
65
|
+
confirm = false,
|
|
66
|
+
} = args;
|
|
67
|
+
|
|
68
|
+
const hasRule = Boolean(
|
|
69
|
+
pattern || prefix || suffix || find !== null || new_extension,
|
|
70
|
+
);
|
|
71
|
+
if (!hasRule) {
|
|
72
|
+
throw invalid(
|
|
73
|
+
'没有给出重命名规则',
|
|
74
|
+
'请说明要怎么改,例如加前缀、替换文字、按序号编号,或者改扩展名。',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
79
|
+
extensions: only_extensions && only_extensions.length > 0 ? only_extensions : null,
|
|
80
|
+
label: '文件',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const logDir = await resolveOutputDir({ inputs: files });
|
|
84
|
+
|
|
85
|
+
const items = await planRename(files, {
|
|
86
|
+
pattern,
|
|
87
|
+
prefix,
|
|
88
|
+
suffix,
|
|
89
|
+
find,
|
|
90
|
+
replace,
|
|
91
|
+
newExtension: new_extension,
|
|
92
|
+
startNumber: start_number,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const previewRows = items.map((item, index) => [
|
|
96
|
+
String(index + 1),
|
|
97
|
+
item.from,
|
|
98
|
+
item.to,
|
|
99
|
+
item.status === 'ready'
|
|
100
|
+
? '待执行'
|
|
101
|
+
: item.status === 'unchanged'
|
|
102
|
+
? '无变化'
|
|
103
|
+
: `跳过:${item.conflictReason}`,
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const ready = items.filter((i) => i.status === 'ready');
|
|
107
|
+
const unchanged = items.filter((i) => i.status === 'unchanged');
|
|
108
|
+
const conflicts = items.filter((i) => i.status === 'conflict');
|
|
109
|
+
|
|
110
|
+
const notes = [];
|
|
111
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
112
|
+
|
|
113
|
+
// 预览模式:只告诉用户会发生什么
|
|
114
|
+
if (!confirm) {
|
|
115
|
+
if (ready.length === 0) {
|
|
116
|
+
notes.push('没有需要改动的文件(要么名字本来就是这个规则,要么都存在冲突)。');
|
|
117
|
+
} else {
|
|
118
|
+
notes.push(
|
|
119
|
+
`以上是预览,**还没有改动任何文件**。确认没问题后,再次调用 batch_rename 并把 confirm 设为 true 即可执行。`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (conflicts.length > 0) {
|
|
123
|
+
notes.push(`⚠️ 有 ${conflicts.length} 个文件因为冲突会被跳过,不会影响其他文件。`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return buildResult({
|
|
127
|
+
title: `🔍 重命名预览:${ready.length} 个待执行,${unchanged.length} 个无变化,${conflicts.length} 个有冲突。`,
|
|
128
|
+
table: mdTable(['#', '原文件名', '新文件名', '状态'], previewRows.slice(0, MAX_INLINE_ROWS)),
|
|
129
|
+
notes,
|
|
130
|
+
data: {
|
|
131
|
+
willChange: ready.length,
|
|
132
|
+
unchanged: unchanged.length,
|
|
133
|
+
conflicts: conflicts.length,
|
|
134
|
+
executed: false,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 执行模式
|
|
140
|
+
if (ready.length === 0) {
|
|
141
|
+
return buildResult({
|
|
142
|
+
title: '没有需要改动的文件,未做任何操作。',
|
|
143
|
+
table: mdTable(['#', '原文件名', '新文件名', '状态'], previewRows.slice(0, MAX_INLINE_ROWS)),
|
|
144
|
+
notes,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { done, skipped, logPath } = await executeRename(items, { logDirectory: logDir });
|
|
149
|
+
|
|
150
|
+
const resultRows = [
|
|
151
|
+
...done.map((item) => [item.from, item.to, '已改名']),
|
|
152
|
+
...skipped.map((item) => [
|
|
153
|
+
item.from,
|
|
154
|
+
item.to,
|
|
155
|
+
item.status === 'unchanged' ? '无变化' : `跳过:${item.conflictReason}`,
|
|
156
|
+
]),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const finalNotes = [
|
|
160
|
+
`成功改名 ${done.length} 个文件。`,
|
|
161
|
+
logPath
|
|
162
|
+
? `回滚记录已保存:\`${displayPath(logPath)}\`。如果改得不满意,直接说"撤销重命名"即可还原。`
|
|
163
|
+
: '',
|
|
164
|
+
...warnings.map((w) => `· ${w}`),
|
|
165
|
+
].filter(Boolean);
|
|
166
|
+
|
|
167
|
+
return buildResult({
|
|
168
|
+
title: `🏷️ 重命名完成:${done.length} 个成功,${skipped.length} 个跳过。`,
|
|
169
|
+
table: mdTable(['原文件名', '新文件名', '结果'], resultRows.slice(0, MAX_INLINE_ROWS)),
|
|
170
|
+
notes: finalNotes,
|
|
171
|
+
data: {
|
|
172
|
+
renamed: done.length,
|
|
173
|
+
skipped: skipped.length,
|
|
174
|
+
logFile: logPath,
|
|
175
|
+
executed: true,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
defineTool(server, {
|
|
182
|
+
name: 'rename_undo',
|
|
183
|
+
title: '撤销重命名',
|
|
184
|
+
action: '撤销重命名',
|
|
185
|
+
description:
|
|
186
|
+
'把上一次批量重命名的结果还原。默认会自动找最近一次的改名记录;如果用户明确给了记录文件路径,用 log_file 传进来。只要文件还在原位、名字没被再次改过,就能完整还原。',
|
|
187
|
+
inputSchema: {
|
|
188
|
+
log_file: z
|
|
189
|
+
.string()
|
|
190
|
+
.optional()
|
|
191
|
+
.describe('重命名记录文件的完整路径。批次重命名完成后返回过这个路径。'),
|
|
192
|
+
search_directory: z
|
|
193
|
+
.string()
|
|
194
|
+
.optional()
|
|
195
|
+
.describe('如果不确定记录在哪,给出当时重命名文件所在的目录,会去里面的 file-toolkit-output 找最近一次记录。'),
|
|
196
|
+
},
|
|
197
|
+
handler: async (args) => {
|
|
198
|
+
const { log_file, search_directory } = args;
|
|
199
|
+
|
|
200
|
+
let logDir;
|
|
201
|
+
if (search_directory) {
|
|
202
|
+
logDir = path.join(path.resolve(search_directory), OUTPUT_DIR_NAME);
|
|
203
|
+
} else if (log_file) {
|
|
204
|
+
logDir = path.dirname(path.resolve(log_file));
|
|
205
|
+
} else {
|
|
206
|
+
logDir = path.join(process.cwd(), OUTPUT_DIR_NAME);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const logPath = await findLatestLog(logDir, log_file ?? null);
|
|
210
|
+
if (!logPath) {
|
|
211
|
+
throw invalid(
|
|
212
|
+
'没有找到改名记录',
|
|
213
|
+
`我在 ${displayPath(logDir)} 里没有找到 ${RENAME_LOG_PREFIX}*.json。` +
|
|
214
|
+
'请把当时重命名文件所在的目录用 search_directory 告诉我,或直接给出 log_file 的完整路径。',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let stat;
|
|
219
|
+
try {
|
|
220
|
+
stat = await fs.stat(logPath);
|
|
221
|
+
} catch {
|
|
222
|
+
throw invalid('这个记录文件不存在', `路径:${logPath}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const { restored, missing } = await undoRename(logPath);
|
|
226
|
+
|
|
227
|
+
const rows = restored.map((r) => [path.basename(r.from), path.basename(r.to), '已还原']);
|
|
228
|
+
|
|
229
|
+
const notes = [
|
|
230
|
+
`依据的记录文件:\`${displayPath(logPath)}\`(${stat.size} 字节)`,
|
|
231
|
+
];
|
|
232
|
+
if (missing.length > 0) {
|
|
233
|
+
notes.push(
|
|
234
|
+
`⚠️ 有 ${missing.length} 个文件不在原来的位置了(可能已被移动或删除),这部分无法还原。`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return buildResult({
|
|
239
|
+
title: `↩️ 撤销完成:已还原 ${restored.length} 个文件名${missing.length > 0 ? `,${missing.length} 个找不到` : ''}。`,
|
|
240
|
+
table: mdTable(['当前文件名', '还原为', '结果'], rows.slice(0, MAX_INLINE_ROWS)),
|
|
241
|
+
notes,
|
|
242
|
+
data: {
|
|
243
|
+
restored: restored.length,
|
|
244
|
+
missing: missing.length,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
defineTool(server, {
|
|
251
|
+
name: 'file_info',
|
|
252
|
+
title: '查看文件信息',
|
|
253
|
+
action: '读取文件信息',
|
|
254
|
+
description:
|
|
255
|
+
'查看文件的基本信息:大小、类型、创建和修改时间、MIME 类型。图片会额外给出宽高和格式,PDF 会额外给出页数。支持一次查看多个文件或整个文件夹。',
|
|
256
|
+
inputSchema: {
|
|
257
|
+
input_files: z
|
|
258
|
+
.array(z.string())
|
|
259
|
+
.describe('要查看的文件路径,可以传文件也可以传文件夹,支持一次多个。'),
|
|
260
|
+
only_extensions: z
|
|
261
|
+
.array(z.string())
|
|
262
|
+
.optional()
|
|
263
|
+
.describe('传文件夹时,只查看这些后缀的文件,例如 ["jpg","png"]。'),
|
|
264
|
+
},
|
|
265
|
+
handler: async (args) => {
|
|
266
|
+
const { input_files, only_extensions = null } = args;
|
|
267
|
+
|
|
268
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
269
|
+
extensions: only_extensions && only_extensions.length > 0 ? only_extensions : null,
|
|
270
|
+
label: '文件',
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const results = await runBatch(files, async (file) => inspectFile(file));
|
|
274
|
+
|
|
275
|
+
const notes = [];
|
|
276
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
277
|
+
|
|
278
|
+
// 单个文件:用逐字段的形式,读起来更清楚
|
|
279
|
+
if (results.length === 1 && results[0].ok) {
|
|
280
|
+
const info = results[0];
|
|
281
|
+
const lines = [
|
|
282
|
+
`📄 **${info.name}**`,
|
|
283
|
+
'',
|
|
284
|
+
`- 位置:\`${displayPath(info.path)}\``,
|
|
285
|
+
`- 类型:${info.kind}(MIME:${info.mimeType})`,
|
|
286
|
+
`- 后缀:${info.extension ? `.${info.extension}` : '无'}`,
|
|
287
|
+
`- 大小:${formatBytes(info.sizeBytes)}(${info.sizeBytes.toLocaleString('en-US')} 字节)`,
|
|
288
|
+
`- 创建时间:${info.createdAt ?? '系统未提供'}`,
|
|
289
|
+
`- 修改时间:${info.modifiedAt ?? '未知'}`,
|
|
290
|
+
];
|
|
291
|
+
if (info.width && info.height) {
|
|
292
|
+
lines.push(`- 图片尺寸:${info.width}×${info.height}(格式 ${info.imageFormat})`);
|
|
293
|
+
}
|
|
294
|
+
if (info.pdfPages !== null) {
|
|
295
|
+
lines.push(`- PDF 页数:${info.pdfPages} 页`);
|
|
296
|
+
}
|
|
297
|
+
if (info.note) lines.push(`- 注意:${info.note}`);
|
|
298
|
+
for (const w of warnings) lines.push(`- ${w}`);
|
|
299
|
+
lines.push('', '```json', JSON.stringify(info, null, 2), '```');
|
|
300
|
+
return lines.join('\n');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const rows = results.map((r) => {
|
|
304
|
+
if (!r.ok) {
|
|
305
|
+
return [path.basename(r.file), '—', '—', '—', describeError(r.error, { action: '读取' })];
|
|
306
|
+
}
|
|
307
|
+
return [
|
|
308
|
+
r.name,
|
|
309
|
+
r.kind,
|
|
310
|
+
formatBytes(r.sizeBytes),
|
|
311
|
+
r.width && r.height
|
|
312
|
+
? `${r.width}×${r.height}`
|
|
313
|
+
: r.pdfPages !== null
|
|
314
|
+
? `${r.pdfPages} 页`
|
|
315
|
+
: '—',
|
|
316
|
+
r.modifiedAt ?? '未知',
|
|
317
|
+
];
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const okResults = results.filter((r) => r.ok);
|
|
321
|
+
const totalBytes = okResults.reduce((sum, r) => sum + r.sizeBytes, 0);
|
|
322
|
+
|
|
323
|
+
if (results.length > MAX_INLINE_ROWS) {
|
|
324
|
+
notes.push(`结果较多,上面只列出前 ${MAX_INLINE_ROWS} 个,完整数据见下方 JSON。`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return buildResult({
|
|
328
|
+
title: `📂 ${summarize('文件信息读取', results)}合计 ${formatBytes(totalBytes)}。`,
|
|
329
|
+
table: mdTable(['文件名', '类型', '大小', '尺寸 / 页数', '修改时间'], rows.slice(0, MAX_INLINE_ROWS)),
|
|
330
|
+
notes,
|
|
331
|
+
data: {
|
|
332
|
+
count: results.length,
|
|
333
|
+
totalBytes,
|
|
334
|
+
items: okResults.map((r) => ({
|
|
335
|
+
name: r.name,
|
|
336
|
+
path: r.path,
|
|
337
|
+
kind: r.kind,
|
|
338
|
+
sizeBytes: r.sizeBytes,
|
|
339
|
+
width: r.width,
|
|
340
|
+
height: r.height,
|
|
341
|
+
pdfPages: r.pdfPages,
|
|
342
|
+
modifiedAt: r.modifiedAt,
|
|
343
|
+
})),
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { defineTool } from '../lib/registry.js';
|
|
5
|
+
import { runBatch } from '../lib/batch.js';
|
|
6
|
+
import {
|
|
7
|
+
expandInputs,
|
|
8
|
+
resolveOutputDir,
|
|
9
|
+
uniquePath,
|
|
10
|
+
assertNotOverwritingInput,
|
|
11
|
+
displayPath,
|
|
12
|
+
} from '../lib/paths.js';
|
|
13
|
+
import {
|
|
14
|
+
compressImage,
|
|
15
|
+
convertImage,
|
|
16
|
+
resizeImage,
|
|
17
|
+
IMAGE_EXTENSIONS,
|
|
18
|
+
normalizeFormat,
|
|
19
|
+
} from '../lib/image.js';
|
|
20
|
+
import { buildResult, mdTable, summarize, formatBytes, formatDelta } from '../lib/result.js';
|
|
21
|
+
import { describeError, invalid } from '../lib/errors.js';
|
|
22
|
+
|
|
23
|
+
const EXT_FOR_FORMAT = { jpeg: 'jpg', png: 'png', webp: 'webp' };
|
|
24
|
+
|
|
25
|
+
const INPUT_FILES = z
|
|
26
|
+
.array(z.string())
|
|
27
|
+
.describe('要处理的图片路径,可以传文件也可以传文件夹(会自动收集里面的图片),支持一次传多个。');
|
|
28
|
+
|
|
29
|
+
const OUTPUT_DIRECTORY = z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe('输出目录。不填则默认写到第一个文件所在目录下的 file-toolkit-output 文件夹,原文件不会被改动。');
|
|
33
|
+
|
|
34
|
+
export function registerImageTools(server) {
|
|
35
|
+
defineTool(server, {
|
|
36
|
+
name: 'image_compress',
|
|
37
|
+
title: '压缩图片',
|
|
38
|
+
action: '压缩图片',
|
|
39
|
+
description:
|
|
40
|
+
'压缩 jpg / png / webp 图片的体积。可以指定目标体积(例如"压到 1MB 以下"),工具会自动调画质去逼近;也可以直接指定画质。支持批量处理整个文件夹。需要缩小图片尺寸时用 image_resize,需要换格式时用 image_convert。',
|
|
41
|
+
inputSchema: {
|
|
42
|
+
input_files: INPUT_FILES,
|
|
43
|
+
target_size_kb: z
|
|
44
|
+
.number()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe('期望压缩到的体积,单位 KB。例如用户说"压到 500KB 以下"就传 500。不填则改用 quality 参数。'),
|
|
47
|
+
quality: z
|
|
48
|
+
.number()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('固定压缩质量 1-100,数值越小体积越小,默认 80。只有在不填 target_size_kb 时才生效。'),
|
|
51
|
+
max_width: z.number().optional().describe('顺便把图片宽度限制到不超过这个像素值。'),
|
|
52
|
+
max_height: z.number().optional().describe('顺便把图片高度限制到不超过这个像素值。'),
|
|
53
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
54
|
+
},
|
|
55
|
+
handler: async (args) => {
|
|
56
|
+
const { input_files, target_size_kb, quality, max_width, max_height, output_directory } = args;
|
|
57
|
+
|
|
58
|
+
if (target_size_kb !== undefined && quality !== undefined) {
|
|
59
|
+
throw invalid(
|
|
60
|
+
'不能同时指定目标体积和固定画质',
|
|
61
|
+
'请二选一:想自动逼近某个体积就只给 target_size_kb,想固定画质就只给 quality。',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
66
|
+
extensions: IMAGE_EXTENSIONS,
|
|
67
|
+
label: '图片',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
71
|
+
|
|
72
|
+
const results = await runBatch(files, async (file) => {
|
|
73
|
+
const before = (await fs.stat(file)).size;
|
|
74
|
+
const out = await compressImage(file, {
|
|
75
|
+
targetSize: target_size_kb !== undefined ? Math.round(target_size_kb * 1024) : null,
|
|
76
|
+
quality: quality !== undefined ? quality : null,
|
|
77
|
+
maxWidth: max_width ?? null,
|
|
78
|
+
maxHeight: max_height ?? null,
|
|
79
|
+
});
|
|
80
|
+
const target = await uniquePath(outputDir, path.basename(file));
|
|
81
|
+
assertNotOverwritingInput(target, files);
|
|
82
|
+
await fs.writeFile(target, out.buffer);
|
|
83
|
+
return {
|
|
84
|
+
before,
|
|
85
|
+
after: out.buffer.length,
|
|
86
|
+
outPath: target,
|
|
87
|
+
reached: out.reachedTarget,
|
|
88
|
+
usedQuality: out.quality,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const rows = results.map((r) => {
|
|
93
|
+
if (!r.ok) {
|
|
94
|
+
return [path.basename(r.file), '—', '—', '失败', describeError(r.error, { action: '压缩' })];
|
|
95
|
+
}
|
|
96
|
+
return [
|
|
97
|
+
path.basename(r.file),
|
|
98
|
+
formatBytes(r.before),
|
|
99
|
+
formatBytes(r.after),
|
|
100
|
+
formatDelta(r.before, r.after),
|
|
101
|
+
displayPath(r.outPath),
|
|
102
|
+
];
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const notes = [];
|
|
106
|
+
const missed = results.filter((r) => r.ok && r.reached === false);
|
|
107
|
+
if (missed.length > 0) {
|
|
108
|
+
notes.push(
|
|
109
|
+
`⚠️ 有 ${missed.length} 个文件即使降到最低画质仍然超过目标体积,已按能达到的最小体积输出。` +
|
|
110
|
+
'如果必须严格达标,可以再用 image_resize 限制尺寸,或者转成 webp。',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
114
|
+
|
|
115
|
+
return buildResult({
|
|
116
|
+
title: `🗜️ ${summarize('图片压缩', results)}`,
|
|
117
|
+
table: mdTable(['文件', '原大小', '压缩后', '变化', '输出'], rows),
|
|
118
|
+
notes,
|
|
119
|
+
outputDir: displayPath(outputDir),
|
|
120
|
+
data: {
|
|
121
|
+
succeeded: results.filter((r) => r.ok).length,
|
|
122
|
+
failed: results.filter((r) => !r.ok).length,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
defineTool(server, {
|
|
129
|
+
name: 'image_convert',
|
|
130
|
+
title: '转换图片格式',
|
|
131
|
+
action: '转换图片格式',
|
|
132
|
+
description:
|
|
133
|
+
'在 jpg / png / webp 之间互转图片格式,支持批量。把 png 的透明背景转成 jpg 时会自动铺白底。只处理扩展名匹配的图片,其他文件会被自动忽略。',
|
|
134
|
+
inputSchema: {
|
|
135
|
+
input_files: INPUT_FILES,
|
|
136
|
+
output_format: z
|
|
137
|
+
.enum(['jpg', 'jpeg', 'png', 'webp'])
|
|
138
|
+
.describe('目标格式。用户说"转成 JPG"就传 jpg,"转成 webp"就传 webp。'),
|
|
139
|
+
quality: z.number().optional().describe('目标格式的有损压缩质量 1-100,默认 90。png 为无损时该参数影响较小。'),
|
|
140
|
+
max_width: z.number().optional().describe('顺便把宽度限制到不超过这个像素值。'),
|
|
141
|
+
max_height: z.number().optional().describe('顺便把高度限制到不超过这个像素值。'),
|
|
142
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
143
|
+
},
|
|
144
|
+
handler: async (args) => {
|
|
145
|
+
const { input_files, output_format, quality, max_width, max_height, output_directory } = args;
|
|
146
|
+
const format = normalizeFormat(output_format);
|
|
147
|
+
|
|
148
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
149
|
+
extensions: IMAGE_EXTENSIONS,
|
|
150
|
+
label: '图片',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
154
|
+
const targetExt = EXT_FOR_FORMAT[format];
|
|
155
|
+
|
|
156
|
+
const results = await runBatch(files, async (file) => {
|
|
157
|
+
const before = (await fs.stat(file)).size;
|
|
158
|
+
const out = await convertImage(file, {
|
|
159
|
+
format,
|
|
160
|
+
quality: quality ?? 90,
|
|
161
|
+
maxWidth: max_width ?? null,
|
|
162
|
+
maxHeight: max_height ?? null,
|
|
163
|
+
});
|
|
164
|
+
const stem = path.basename(file, path.extname(file));
|
|
165
|
+
const target = await uniquePath(outputDir, `${stem}.${targetExt}`);
|
|
166
|
+
assertNotOverwritingInput(target, files);
|
|
167
|
+
await fs.writeFile(target, out.buffer);
|
|
168
|
+
return {
|
|
169
|
+
before,
|
|
170
|
+
after: out.buffer.length,
|
|
171
|
+
outPath: target,
|
|
172
|
+
width: out.width,
|
|
173
|
+
height: out.height,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const rows = results.map((r) => {
|
|
178
|
+
if (!r.ok) {
|
|
179
|
+
return [path.basename(r.file), '—', '—', '失败', describeError(r.error, { action: '转换' })];
|
|
180
|
+
}
|
|
181
|
+
return [
|
|
182
|
+
path.basename(r.file),
|
|
183
|
+
formatBytes(r.before),
|
|
184
|
+
`${formatBytes(r.after)} (${r.width}×${r.height})`,
|
|
185
|
+
formatDelta(r.before, r.after),
|
|
186
|
+
displayPath(r.outPath),
|
|
187
|
+
];
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const notes = [];
|
|
191
|
+
notes.push(`已统一转换为 ${targetExt.toUpperCase()} 格式。`);
|
|
192
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
193
|
+
|
|
194
|
+
return buildResult({
|
|
195
|
+
title: `🔄 ${summarize('格式转换', results)}`,
|
|
196
|
+
table: mdTable(['文件', '原大小', '转换后', '变化', '输出'], rows),
|
|
197
|
+
notes,
|
|
198
|
+
outputDir: displayPath(outputDir),
|
|
199
|
+
data: {
|
|
200
|
+
format: targetExt,
|
|
201
|
+
succeeded: results.filter((r) => r.ok).length,
|
|
202
|
+
failed: results.filter((r) => !r.ok).length,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
defineTool(server, {
|
|
209
|
+
name: 'image_resize',
|
|
210
|
+
title: '调整图片尺寸',
|
|
211
|
+
action: '调整图片尺寸',
|
|
212
|
+
description:
|
|
213
|
+
'按宽度、高度或百分比缩放图片,支持批量。fit_mode 决定缩放方式:fit 等比缩放到不超过给定宽高(默认)、max 只缩小绝不放大、cover 裁切填满、stretch 强制拉伸。不要用这个工具改格式,改格式请用 image_convert。',
|
|
214
|
+
inputSchema: {
|
|
215
|
+
input_files: INPUT_FILES,
|
|
216
|
+
width: z.number().optional().describe('目标宽度(像素)。'),
|
|
217
|
+
height: z.number().optional().describe('目标高度(像素)。'),
|
|
218
|
+
scale_percent: z
|
|
219
|
+
.number()
|
|
220
|
+
.optional()
|
|
221
|
+
.describe('按百分比缩放,例如 50 表示缩小到一半。与 width / height 三选一。'),
|
|
222
|
+
fit_mode: z
|
|
223
|
+
.enum(['fit', 'max', 'cover', 'stretch'])
|
|
224
|
+
.optional()
|
|
225
|
+
.describe(
|
|
226
|
+
'缩放方式。fit=等比缩放到不超过给定宽高;max=等比缩放但绝不放大(用户说"最大宽度"时用它);cover=等比缩放并裁掉多余部分;stretch=强制拉伸到精确尺寸。默认 fit。',
|
|
227
|
+
),
|
|
228
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
229
|
+
},
|
|
230
|
+
handler: async (args) => {
|
|
231
|
+
const { input_files, width, height, scale_percent, fit_mode, output_directory } = args;
|
|
232
|
+
|
|
233
|
+
if (width === undefined && height === undefined && scale_percent === undefined) {
|
|
234
|
+
throw invalid(
|
|
235
|
+
'没有指定新的尺寸',
|
|
236
|
+
'请告诉我要缩到多宽、多高,或者按百分之多少缩放,例如 width=1200。',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
241
|
+
extensions: IMAGE_EXTENSIONS,
|
|
242
|
+
label: '图片',
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
246
|
+
|
|
247
|
+
const results = await runBatch(files, async (file) => {
|
|
248
|
+
const out = await resizeImage(file, {
|
|
249
|
+
width: width ?? null,
|
|
250
|
+
height: height ?? null,
|
|
251
|
+
scalePercent: scale_percent ?? null,
|
|
252
|
+
fitMode: fit_mode ?? 'fit',
|
|
253
|
+
});
|
|
254
|
+
const target = await uniquePath(outputDir, path.basename(file));
|
|
255
|
+
assertNotOverwritingInput(target, files);
|
|
256
|
+
await fs.writeFile(target, out.buffer);
|
|
257
|
+
return {
|
|
258
|
+
outPath: target,
|
|
259
|
+
width: out.width,
|
|
260
|
+
height: out.height,
|
|
261
|
+
originalWidth: out.originalWidth,
|
|
262
|
+
originalHeight: out.originalHeight,
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const rows = results.map((r) => {
|
|
267
|
+
if (!r.ok) {
|
|
268
|
+
return [path.basename(r.file), '—', '—', '失败', describeError(r.error, { action: '缩放' })];
|
|
269
|
+
}
|
|
270
|
+
return [
|
|
271
|
+
path.basename(r.file),
|
|
272
|
+
`${r.originalWidth}×${r.originalHeight}`,
|
|
273
|
+
`${r.width}×${r.height}`,
|
|
274
|
+
'成功',
|
|
275
|
+
displayPath(r.outPath),
|
|
276
|
+
];
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const notes = [];
|
|
280
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
281
|
+
|
|
282
|
+
return buildResult({
|
|
283
|
+
title: `📐 ${summarize('尺寸调整', results)}`,
|
|
284
|
+
table: mdTable(['文件', '原尺寸', '新尺寸', '结果', '输出'], rows),
|
|
285
|
+
notes,
|
|
286
|
+
outputDir: displayPath(outputDir),
|
|
287
|
+
data: {
|
|
288
|
+
succeeded: results.filter((r) => r.ok).length,
|
|
289
|
+
failed: results.filter((r) => !r.ok).length,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}
|