chainlesschain 0.43.2 → 0.44.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/README.md +123 -18
- package/bin/chainlesschain.js +0 -0
- package/package.json +4 -2
- package/src/commands/init.js +2306 -1
- package/src/commands/skill.js +166 -0
- package/src/lib/skill-loader.js +4 -0
- package/src/lib/skill-packs/generator.js +630 -0
- package/src/lib/skill-packs/schema.js +462 -0
package/src/commands/init.js
CHANGED
|
@@ -11,6 +11,2106 @@ import path from "path";
|
|
|
11
11
|
import { logger } from "../lib/logger.js";
|
|
12
12
|
import { isInsideProject, findProjectRoot } from "../lib/project-detector.js";
|
|
13
13
|
|
|
14
|
+
// Workspace skill templates for ai-media-creator
|
|
15
|
+
const SKILL_TEMPLATES = {
|
|
16
|
+
"comfyui-image": {
|
|
17
|
+
md: `---
|
|
18
|
+
name: comfyui-image
|
|
19
|
+
display-name: ComfyUI 图像生成
|
|
20
|
+
category: media
|
|
21
|
+
description: 通过 ComfyUI REST API 生成图像(文生图/图生图),支持自定义工作流
|
|
22
|
+
version: 1.0.0
|
|
23
|
+
author: ChainlessChain
|
|
24
|
+
parameters:
|
|
25
|
+
- name: prompt
|
|
26
|
+
type: string
|
|
27
|
+
required: true
|
|
28
|
+
description: 图像生成提示词(正向)
|
|
29
|
+
- name: negative_prompt
|
|
30
|
+
type: string
|
|
31
|
+
required: false
|
|
32
|
+
description: 负向提示词
|
|
33
|
+
default: ""
|
|
34
|
+
- name: width
|
|
35
|
+
type: number
|
|
36
|
+
required: false
|
|
37
|
+
description: 图像宽度(像素)
|
|
38
|
+
default: 512
|
|
39
|
+
- name: height
|
|
40
|
+
type: number
|
|
41
|
+
required: false
|
|
42
|
+
description: 图像高度(像素)
|
|
43
|
+
default: 512
|
|
44
|
+
- name: steps
|
|
45
|
+
type: number
|
|
46
|
+
required: false
|
|
47
|
+
description: 采样步数
|
|
48
|
+
default: 20
|
|
49
|
+
- name: workflow
|
|
50
|
+
type: string
|
|
51
|
+
required: false
|
|
52
|
+
description: 自定义工作流 JSON 文件路径(位于 workflows/ 目录)
|
|
53
|
+
execution-mode: direct
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
# ComfyUI 图像生成
|
|
57
|
+
|
|
58
|
+
通过本地 ComfyUI 服务(默认 http://localhost:8188)生成 AI 图像。
|
|
59
|
+
|
|
60
|
+
## 使用示例
|
|
61
|
+
|
|
62
|
+
\`\`\`bash
|
|
63
|
+
# 简单文生图
|
|
64
|
+
chainlesschain skill run comfyui-image "a sunset over mountains, oil painting style"
|
|
65
|
+
|
|
66
|
+
# 指定尺寸和步数
|
|
67
|
+
chainlesschain skill run comfyui-image "portrait of a warrior" --args '{"width":768,"height":1024,"steps":30}'
|
|
68
|
+
|
|
69
|
+
# 使用自定义工作流
|
|
70
|
+
chainlesschain skill run comfyui-image "cyberpunk city" --args '{"workflow":"workflows/my-workflow.json"}'
|
|
71
|
+
\`\`\`
|
|
72
|
+
|
|
73
|
+
## 前提条件
|
|
74
|
+
|
|
75
|
+
- ComfyUI 已安装并运行(默认端口 8188)
|
|
76
|
+
- 至少加载了一个 Stable Diffusion 模型
|
|
77
|
+
- 安装了 ComfyUI 的 SaveImage 节点(默认已包含)
|
|
78
|
+
|
|
79
|
+
## cli-anything 集成说明
|
|
80
|
+
|
|
81
|
+
如果你有带 CLI 接口的 AI 图像工具(如第三方 ComfyUI CLI 包装、InvokeAI CLI 等),可以通过以下方式注册:
|
|
82
|
+
|
|
83
|
+
\`\`\`bash
|
|
84
|
+
chainlesschain cli-anything register <tool-name>
|
|
85
|
+
\`\`\`
|
|
86
|
+
`,
|
|
87
|
+
handler: `/**
|
|
88
|
+
* ComfyUI Image Generation Skill Handler
|
|
89
|
+
* Calls ComfyUI REST API to generate images via Stable Diffusion workflows.
|
|
90
|
+
*
|
|
91
|
+
* Requirements: ComfyUI running at COMFYUI_URL (default: http://localhost:8188)
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
const http = require("http");
|
|
95
|
+
const https = require("https");
|
|
96
|
+
const fs = require("fs");
|
|
97
|
+
const path = require("path");
|
|
98
|
+
const url = require("url");
|
|
99
|
+
|
|
100
|
+
const COMFYUI_URL = process.env.COMFYUI_URL || "http://localhost:8188";
|
|
101
|
+
const POLL_INTERVAL_MS = 2000;
|
|
102
|
+
const POLL_TIMEOUT_MS = 120000;
|
|
103
|
+
|
|
104
|
+
function httpRequest(urlStr, options = {}, body = null) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const parsed = url.parse(urlStr);
|
|
107
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
108
|
+
const reqOpts = {
|
|
109
|
+
hostname: parsed.hostname,
|
|
110
|
+
port: parsed.port,
|
|
111
|
+
path: parsed.path,
|
|
112
|
+
method: options.method || "GET",
|
|
113
|
+
headers: options.headers || {},
|
|
114
|
+
};
|
|
115
|
+
const req = lib.request(reqOpts, (res) => {
|
|
116
|
+
let data = "";
|
|
117
|
+
res.on("data", (chunk) => (data += chunk.toString("utf8")));
|
|
118
|
+
res.on("end", () => {
|
|
119
|
+
try {
|
|
120
|
+
resolve({ status: res.statusCode, body: JSON.parse(data) });
|
|
121
|
+
} catch {
|
|
122
|
+
resolve({ status: res.statusCode, body: data });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
req.on("error", reject);
|
|
127
|
+
if (body) req.write(typeof body === "string" ? body : JSON.stringify(body));
|
|
128
|
+
req.end();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildDefaultWorkflow(params) {
|
|
133
|
+
const { prompt, negative_prompt, width, height, steps } = params;
|
|
134
|
+
return {
|
|
135
|
+
"1": {
|
|
136
|
+
class_type: "CheckpointLoaderSimple",
|
|
137
|
+
inputs: { ckpt_name: "v1-5-pruned-emaonly.ckpt" },
|
|
138
|
+
},
|
|
139
|
+
"2": {
|
|
140
|
+
class_type: "EmptyLatentImage",
|
|
141
|
+
inputs: { width: width || 512, height: height || 512, batch_size: 1 },
|
|
142
|
+
},
|
|
143
|
+
"3": {
|
|
144
|
+
class_type: "CLIPTextEncode",
|
|
145
|
+
inputs: { text: prompt, clip: ["1", 1] },
|
|
146
|
+
},
|
|
147
|
+
"4": {
|
|
148
|
+
class_type: "CLIPTextEncode",
|
|
149
|
+
inputs: { text: negative_prompt || "", clip: ["1", 1] },
|
|
150
|
+
},
|
|
151
|
+
"5": {
|
|
152
|
+
class_type: "KSampler",
|
|
153
|
+
inputs: {
|
|
154
|
+
model: ["1", 0],
|
|
155
|
+
positive: ["3", 0],
|
|
156
|
+
negative: ["4", 0],
|
|
157
|
+
latent_image: ["2", 0],
|
|
158
|
+
seed: Math.floor(Math.random() * 2 ** 32),
|
|
159
|
+
steps: steps || 20,
|
|
160
|
+
cfg: 7,
|
|
161
|
+
sampler_name: "euler",
|
|
162
|
+
scheduler: "normal",
|
|
163
|
+
denoise: 1,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
"6": {
|
|
167
|
+
class_type: "VAEDecode",
|
|
168
|
+
inputs: { samples: ["5", 0], vae: ["1", 2] },
|
|
169
|
+
},
|
|
170
|
+
"7": {
|
|
171
|
+
class_type: "SaveImage",
|
|
172
|
+
inputs: { images: ["6", 0], filename_prefix: "cc_" },
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function pollUntilDone(promptId) {
|
|
178
|
+
const start = Date.now();
|
|
179
|
+
while (Date.now() - start < POLL_TIMEOUT_MS) {
|
|
180
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
181
|
+
const res = await httpRequest(\`\${COMFYUI_URL}/history/\${promptId}\`);
|
|
182
|
+
if (res.status === 200 && res.body && res.body[promptId]) {
|
|
183
|
+
const hist = res.body[promptId];
|
|
184
|
+
if (hist.status && hist.status.completed) {
|
|
185
|
+
// Collect output images
|
|
186
|
+
const images = [];
|
|
187
|
+
for (const nodeId of Object.keys(hist.outputs || {})) {
|
|
188
|
+
const nodeOut = hist.outputs[nodeId];
|
|
189
|
+
if (nodeOut.images) {
|
|
190
|
+
for (const img of nodeOut.images) {
|
|
191
|
+
images.push({
|
|
192
|
+
filename: img.filename,
|
|
193
|
+
subfolder: img.subfolder || "",
|
|
194
|
+
url: \`\${COMFYUI_URL}/view?filename=\${encodeURIComponent(img.filename)}&subfolder=\${encodeURIComponent(img.subfolder || "")}&type=output\`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { success: true, images };
|
|
200
|
+
}
|
|
201
|
+
if (hist.status && hist.status.status_str === "error") {
|
|
202
|
+
return { success: false, error: "ComfyUI reported workflow error" };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { success: false, error: "Timeout waiting for image generation" };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function comfyuiImageHandler(params) {
|
|
210
|
+
const { prompt, negative_prompt, width, height, steps, workflow } = params;
|
|
211
|
+
|
|
212
|
+
if (!prompt) {
|
|
213
|
+
return { error: "Missing required parameter: prompt" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Check ComfyUI is running
|
|
217
|
+
let statsRes;
|
|
218
|
+
try {
|
|
219
|
+
statsRes = await httpRequest(\`\${COMFYUI_URL}/system_stats\`);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return {
|
|
222
|
+
error: \`Cannot connect to ComfyUI at \${COMFYUI_URL}. Is it running?\`,
|
|
223
|
+
hint: "Start ComfyUI first: python main.py --listen 0.0.0.0",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (statsRes.status !== 200) {
|
|
227
|
+
return { error: \`ComfyUI returned HTTP \${statsRes.status}\` };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Load workflow
|
|
231
|
+
let workflowJson;
|
|
232
|
+
if (workflow) {
|
|
233
|
+
const wfPath = path.isAbsolute(workflow)
|
|
234
|
+
? workflow
|
|
235
|
+
: path.join(process.cwd(), workflow);
|
|
236
|
+
if (!fs.existsSync(wfPath)) {
|
|
237
|
+
return { error: \`Workflow file not found: \${wfPath}\` };
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
workflowJson = JSON.parse(fs.readFileSync(wfPath, "utf-8"));
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return { error: \`Failed to parse workflow JSON: \${err.message}\` };
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
workflowJson = buildDefaultWorkflow({ prompt, negative_prompt, width, height, steps });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Submit prompt
|
|
249
|
+
const submitRes = await httpRequest(
|
|
250
|
+
\`\${COMFYUI_URL}/prompt\`,
|
|
251
|
+
{
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: { "Content-Type": "application/json" },
|
|
254
|
+
},
|
|
255
|
+
{ prompt: workflowJson },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
if (submitRes.status !== 200 || !submitRes.body || !submitRes.body.prompt_id) {
|
|
259
|
+
return {
|
|
260
|
+
error: \`Failed to submit workflow: HTTP \${submitRes.status}\`,
|
|
261
|
+
detail: submitRes.body,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const promptId = submitRes.body.prompt_id;
|
|
266
|
+
console.log(\`[comfyui-image] Submitted prompt \${promptId}, waiting...\`);
|
|
267
|
+
|
|
268
|
+
// Poll for result
|
|
269
|
+
const result = await pollUntilDone(promptId);
|
|
270
|
+
if (!result.success) {
|
|
271
|
+
return { error: result.error };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
success: true,
|
|
276
|
+
promptId,
|
|
277
|
+
images: result.images,
|
|
278
|
+
message: \`Generated \${result.images.length} image(s). Open URLs to download:\`,
|
|
279
|
+
urls: result.images.map((i) => i.url),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
comfyuiImageHandler.execute = async (task, _ctx, _skill) => {
|
|
284
|
+
const input = typeof task === "string" ? task : (task.input || task.params?.input || "");
|
|
285
|
+
let p = {};
|
|
286
|
+
try { p = input.trim().startsWith("{") ? JSON.parse(input) : { prompt: input }; }
|
|
287
|
+
catch { p = { prompt: input }; }
|
|
288
|
+
return comfyuiImageHandler(p);
|
|
289
|
+
};
|
|
290
|
+
module.exports = comfyuiImageHandler;
|
|
291
|
+
`,
|
|
292
|
+
},
|
|
293
|
+
"comfyui-video": {
|
|
294
|
+
md: `---
|
|
295
|
+
name: comfyui-video
|
|
296
|
+
display-name: ComfyUI 视频/动画生成
|
|
297
|
+
category: media
|
|
298
|
+
description: 通过 ComfyUI + AnimateDiff 节点生成 AI 动画视频,支持自定义工作流
|
|
299
|
+
version: 1.0.0
|
|
300
|
+
author: ChainlessChain
|
|
301
|
+
parameters:
|
|
302
|
+
- name: prompt
|
|
303
|
+
type: string
|
|
304
|
+
required: true
|
|
305
|
+
description: 视频内容描述提示词
|
|
306
|
+
- name: frames
|
|
307
|
+
type: number
|
|
308
|
+
required: false
|
|
309
|
+
description: 帧数(AnimateDiff 推荐 16 的倍数)
|
|
310
|
+
default: 16
|
|
311
|
+
- name: fps
|
|
312
|
+
type: number
|
|
313
|
+
required: false
|
|
314
|
+
description: 输出帧率
|
|
315
|
+
default: 8
|
|
316
|
+
- name: workflow
|
|
317
|
+
type: string
|
|
318
|
+
required: true
|
|
319
|
+
description: AnimateDiff 工作流 JSON 文件路径(位于 workflows/ 目录)
|
|
320
|
+
execution-mode: direct
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
# ComfyUI 视频/动画生成
|
|
324
|
+
|
|
325
|
+
使用 ComfyUI + AnimateDiff 扩展生成 AI 动画视频。
|
|
326
|
+
|
|
327
|
+
## 前提条件
|
|
328
|
+
|
|
329
|
+
1. ComfyUI 已安装并运行(默认端口 8188)
|
|
330
|
+
2. 安装 AnimateDiff ComfyUI 扩展:
|
|
331
|
+
\`\`\`bash
|
|
332
|
+
cd ComfyUI/custom_nodes
|
|
333
|
+
git clone https://github.com/guoyww/AnimateDiff-EvolutionaryFramework
|
|
334
|
+
\`\`\`
|
|
335
|
+
3. 下载 AnimateDiff 模型至 \`ComfyUI/models/animatediff_models/\`
|
|
336
|
+
4. 准备工作流 JSON 文件(保存至 \`workflows/\` 目录)
|
|
337
|
+
|
|
338
|
+
## 使用示例
|
|
339
|
+
|
|
340
|
+
\`\`\`bash
|
|
341
|
+
# 使用自定义 AnimateDiff 工作流
|
|
342
|
+
chainlesschain skill run comfyui-video "a cat walking" --args '{"workflow":"workflows/animatediff.json","frames":16}'
|
|
343
|
+
\`\`\`
|
|
344
|
+
|
|
345
|
+
## 工作流说明
|
|
346
|
+
|
|
347
|
+
由于 AnimateDiff 工作流需要针对具体模型定制,本技能要求提供工作流文件。
|
|
348
|
+
在 workflows/README.md 中可以找到工作流模板说明。
|
|
349
|
+
|
|
350
|
+
## cli-anything 集成
|
|
351
|
+
|
|
352
|
+
如果有支持 CLI 的视频生成工具(如 deforum-cli、svd-cli 等),可以通过以下命令注册:
|
|
353
|
+
|
|
354
|
+
\`\`\`bash
|
|
355
|
+
chainlesschain cli-anything register <tool-name>
|
|
356
|
+
\`\`\`
|
|
357
|
+
`,
|
|
358
|
+
handler: `/**
|
|
359
|
+
* ComfyUI Video/Animation Generation Skill Handler
|
|
360
|
+
* Uses ComfyUI + AnimateDiff extension to generate animated videos.
|
|
361
|
+
*
|
|
362
|
+
* Requirements:
|
|
363
|
+
* - ComfyUI running at COMFYUI_URL (default: http://localhost:8188)
|
|
364
|
+
* - AnimateDiff custom node installed
|
|
365
|
+
* - A workflow JSON file (required — provide via 'workflow' parameter)
|
|
366
|
+
*/
|
|
367
|
+
|
|
368
|
+
const http = require("http");
|
|
369
|
+
const https = require("https");
|
|
370
|
+
const fs = require("fs");
|
|
371
|
+
const path = require("path");
|
|
372
|
+
const url = require("url");
|
|
373
|
+
|
|
374
|
+
const COMFYUI_URL = process.env.COMFYUI_URL || "http://localhost:8188";
|
|
375
|
+
const POLL_INTERVAL_MS = 3000;
|
|
376
|
+
const POLL_TIMEOUT_MS = 300000; // 5 min for video
|
|
377
|
+
|
|
378
|
+
function httpRequest(urlStr, options = {}, body = null) {
|
|
379
|
+
return new Promise((resolve, reject) => {
|
|
380
|
+
const parsed = url.parse(urlStr);
|
|
381
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
382
|
+
const reqOpts = {
|
|
383
|
+
hostname: parsed.hostname,
|
|
384
|
+
port: parsed.port,
|
|
385
|
+
path: parsed.path,
|
|
386
|
+
method: options.method || "GET",
|
|
387
|
+
headers: options.headers || {},
|
|
388
|
+
};
|
|
389
|
+
const req = lib.request(reqOpts, (res) => {
|
|
390
|
+
let data = "";
|
|
391
|
+
res.on("data", (chunk) => (data += chunk.toString("utf8")));
|
|
392
|
+
res.on("end", () => {
|
|
393
|
+
try {
|
|
394
|
+
resolve({ status: res.statusCode, body: JSON.parse(data) });
|
|
395
|
+
} catch {
|
|
396
|
+
resolve({ status: res.statusCode, body: data });
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
req.on("error", reject);
|
|
401
|
+
if (body) req.write(typeof body === "string" ? body : JSON.stringify(body));
|
|
402
|
+
req.end();
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function pollUntilDone(promptId) {
|
|
407
|
+
const start = Date.now();
|
|
408
|
+
while (Date.now() - start < POLL_TIMEOUT_MS) {
|
|
409
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
410
|
+
const res = await httpRequest(\`\${COMFYUI_URL}/history/\${promptId}\`);
|
|
411
|
+
if (res.status === 200 && res.body && res.body[promptId]) {
|
|
412
|
+
const hist = res.body[promptId];
|
|
413
|
+
if (hist.status && hist.status.completed) {
|
|
414
|
+
const outputs = [];
|
|
415
|
+
for (const nodeId of Object.keys(hist.outputs || {})) {
|
|
416
|
+
const nodeOut = hist.outputs[nodeId];
|
|
417
|
+
// Collect video/gif/image outputs
|
|
418
|
+
for (const key of ["videos", "gifs", "images"]) {
|
|
419
|
+
if (nodeOut[key]) {
|
|
420
|
+
for (const item of nodeOut[key]) {
|
|
421
|
+
outputs.push({
|
|
422
|
+
type: key,
|
|
423
|
+
filename: item.filename,
|
|
424
|
+
subfolder: item.subfolder || "",
|
|
425
|
+
url: \`\${COMFYUI_URL}/view?filename=\${encodeURIComponent(item.filename)}&subfolder=\${encodeURIComponent(item.subfolder || "")}&type=output\`,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { success: true, outputs };
|
|
432
|
+
}
|
|
433
|
+
if (hist.status && hist.status.status_str === "error") {
|
|
434
|
+
return { success: false, error: "ComfyUI reported workflow error" };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { success: false, error: "Timeout waiting for video generation" };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function comfyuiVideoHandler(params) {
|
|
442
|
+
const { prompt, frames, fps, workflow } = params;
|
|
443
|
+
|
|
444
|
+
if (!prompt) {
|
|
445
|
+
return { error: "Missing required parameter: prompt" };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!workflow) {
|
|
449
|
+
return {
|
|
450
|
+
error: "Missing required parameter: workflow",
|
|
451
|
+
hint: [
|
|
452
|
+
"AnimateDiff requires a custom workflow JSON file.",
|
|
453
|
+
"1. Create your AnimateDiff workflow in ComfyUI UI",
|
|
454
|
+
"2. Save it to workflows/ directory in this project",
|
|
455
|
+
"3. Run: chainlesschain skill run comfyui-video \\"your prompt\\" --args \\'{}\\"workflow\\":\\"workflows/your-workflow.json\\"}\\' ",
|
|
456
|
+
"",
|
|
457
|
+
"See workflows/README.md for workflow template guidance.",
|
|
458
|
+
].join("\\n"),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Check ComfyUI is running
|
|
463
|
+
try {
|
|
464
|
+
const statsRes = await httpRequest(\`\${COMFYUI_URL}/system_stats\`);
|
|
465
|
+
if (statsRes.status !== 200) {
|
|
466
|
+
throw new Error(\`HTTP \${statsRes.status}\`);
|
|
467
|
+
}
|
|
468
|
+
} catch (err) {
|
|
469
|
+
return {
|
|
470
|
+
error: \`Cannot connect to ComfyUI at \${COMFYUI_URL}. Is it running?\`,
|
|
471
|
+
hint: "Start ComfyUI first: python main.py --listen 0.0.0.0",
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Load workflow file
|
|
476
|
+
const wfPath = path.isAbsolute(workflow)
|
|
477
|
+
? workflow
|
|
478
|
+
: path.join(process.cwd(), workflow);
|
|
479
|
+
if (!fs.existsSync(wfPath)) {
|
|
480
|
+
return { error: \`Workflow file not found: \${wfPath}\` };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let workflowJson;
|
|
484
|
+
try {
|
|
485
|
+
workflowJson = JSON.parse(fs.readFileSync(wfPath, "utf-8"));
|
|
486
|
+
} catch (err) {
|
|
487
|
+
return { error: \`Failed to parse workflow JSON: \${err.message}\` };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Submit prompt
|
|
491
|
+
const submitRes = await httpRequest(
|
|
492
|
+
\`\${COMFYUI_URL}/prompt\`,
|
|
493
|
+
{
|
|
494
|
+
method: "POST",
|
|
495
|
+
headers: { "Content-Type": "application/json" },
|
|
496
|
+
},
|
|
497
|
+
{ prompt: workflowJson },
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
if (submitRes.status !== 200 || !submitRes.body || !submitRes.body.prompt_id) {
|
|
501
|
+
return {
|
|
502
|
+
error: \`Failed to submit workflow: HTTP \${submitRes.status}\`,
|
|
503
|
+
detail: submitRes.body,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const promptId = submitRes.body.prompt_id;
|
|
508
|
+
console.log(\`[comfyui-video] Submitted prompt \${promptId}, waiting for video...\`);
|
|
509
|
+
|
|
510
|
+
const result = await pollUntilDone(promptId);
|
|
511
|
+
if (!result.success) {
|
|
512
|
+
return { error: result.error };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
success: true,
|
|
517
|
+
promptId,
|
|
518
|
+
outputs: result.outputs,
|
|
519
|
+
message: \`Generated \${result.outputs.length} output(s). URLs:\`,
|
|
520
|
+
urls: result.outputs.map((o) => o.url),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
comfyuiVideoHandler.execute = async (task, _ctx, _skill) => {
|
|
525
|
+
const input = typeof task === "string" ? task : (task.input || task.params?.input || "");
|
|
526
|
+
let p = {};
|
|
527
|
+
try { p = input.trim().startsWith("{") ? JSON.parse(input) : { prompt: input }; }
|
|
528
|
+
catch { p = { prompt: input }; }
|
|
529
|
+
return comfyuiVideoHandler(p);
|
|
530
|
+
};
|
|
531
|
+
module.exports = comfyuiVideoHandler;
|
|
532
|
+
`,
|
|
533
|
+
},
|
|
534
|
+
"audio-gen": {
|
|
535
|
+
md: `---
|
|
536
|
+
name: audio-gen
|
|
537
|
+
display-name: AI 音频生成
|
|
538
|
+
category: media
|
|
539
|
+
description: AI 音频生成(TTS 语音合成 / 音乐生成),支持 edge-tts、piper-tts、ElevenLabs、OpenAI TTS
|
|
540
|
+
version: 1.0.0
|
|
541
|
+
author: ChainlessChain
|
|
542
|
+
parameters:
|
|
543
|
+
- name: text
|
|
544
|
+
type: string
|
|
545
|
+
required: true
|
|
546
|
+
description: 要合成的文本内容
|
|
547
|
+
- name: voice
|
|
548
|
+
type: string
|
|
549
|
+
required: false
|
|
550
|
+
description: 语音名称(edge-tts 示例:zh-CN-XiaoxiaoNeural;OpenAI 示例:alloy)
|
|
551
|
+
default: "zh-CN-XiaoxiaoNeural"
|
|
552
|
+
- name: type
|
|
553
|
+
type: string
|
|
554
|
+
required: false
|
|
555
|
+
description: 生成类型:tts(语音合成)
|
|
556
|
+
default: "tts"
|
|
557
|
+
- name: output
|
|
558
|
+
type: string
|
|
559
|
+
required: false
|
|
560
|
+
description: 输出文件路径(.mp3 或 .wav)
|
|
561
|
+
default: "output.mp3"
|
|
562
|
+
execution-mode: direct
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
# AI 音频生成
|
|
566
|
+
|
|
567
|
+
支持多种后端的 AI 语音合成技能,按以下优先级自动选择可用后端:
|
|
568
|
+
|
|
569
|
+
1. **edge-tts**(推荐,免费)— 微软 Edge TTS,支持多语言
|
|
570
|
+
\`\`\`bash
|
|
571
|
+
pip install edge-tts
|
|
572
|
+
\`\`\`
|
|
573
|
+
|
|
574
|
+
2. **piper-tts**(离线,免费)— 高质量神经网络 TTS
|
|
575
|
+
\`\`\`bash
|
|
576
|
+
pip install piper-tts
|
|
577
|
+
\`\`\`
|
|
578
|
+
|
|
579
|
+
3. **ElevenLabs API**(高质量,付费)— 需设置 \`ELEVENLABS_API_KEY\` 环境变量
|
|
580
|
+
|
|
581
|
+
4. **OpenAI TTS API**(高质量,付费)— 需设置 \`OPENAI_API_KEY\` 环境变量
|
|
582
|
+
|
|
583
|
+
## 使用示例
|
|
584
|
+
|
|
585
|
+
\`\`\`bash
|
|
586
|
+
# 中文语音合成(需安装 edge-tts)
|
|
587
|
+
chainlesschain skill run audio-gen "你好,欢迎使用 ChainlessChain AI 音频生成功能"
|
|
588
|
+
|
|
589
|
+
# 英文语音,指定输出文件
|
|
590
|
+
chainlesschain skill run audio-gen "Hello world" --args '{"voice":"en-US-AriaNeural","output":"hello.mp3"}'
|
|
591
|
+
|
|
592
|
+
# 使用 OpenAI TTS
|
|
593
|
+
OPENAI_API_KEY=sk-xxx chainlesschain skill run audio-gen "Hello" --args '{"voice":"alloy"}'
|
|
594
|
+
\`\`\`
|
|
595
|
+
|
|
596
|
+
## cli-anything 集成
|
|
597
|
+
|
|
598
|
+
如果有支持 CLI 的音频生成工具(如 audiogen-cli、bark-cli 等),可以通过以下命令注册:
|
|
599
|
+
|
|
600
|
+
\`\`\`bash
|
|
601
|
+
chainlesschain cli-anything register <tool-name>
|
|
602
|
+
\`\`\`
|
|
603
|
+
`,
|
|
604
|
+
handler: `/**
|
|
605
|
+
* AI Audio Generation Skill Handler
|
|
606
|
+
* Supports multiple TTS backends: edge-tts, piper-tts, ElevenLabs API, OpenAI TTS.
|
|
607
|
+
* Auto-detects which backend is available and uses the best one.
|
|
608
|
+
*/
|
|
609
|
+
|
|
610
|
+
const { execSync, spawn } = require("child_process");
|
|
611
|
+
const fs = require("fs");
|
|
612
|
+
const path = require("path");
|
|
613
|
+
const https = require("https");
|
|
614
|
+
|
|
615
|
+
function commandExists(cmd) {
|
|
616
|
+
try {
|
|
617
|
+
execSync(\`\${cmd} --version\`, { stdio: "ignore", encoding: "utf-8" });
|
|
618
|
+
return true;
|
|
619
|
+
} catch {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function runEdgeTTS(text, voice, outputPath) {
|
|
625
|
+
return new Promise((resolve, reject) => {
|
|
626
|
+
const args = ["edge-tts", "--voice", voice || "zh-CN-XiaoxiaoNeural", "--text", text, "--write-media", outputPath];
|
|
627
|
+
const proc = spawn("python", ["-m", ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
628
|
+
let stderr = "";
|
|
629
|
+
proc.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
|
630
|
+
proc.on("close", (code) => {
|
|
631
|
+
if (code === 0) resolve(outputPath);
|
|
632
|
+
else reject(new Error(\`edge-tts exited \${code}: \${stderr}\`));
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function callElevenLabsAPI(text, voice, outputPath, apiKey) {
|
|
638
|
+
return new Promise((resolve, reject) => {
|
|
639
|
+
const voiceId = voice || "21m00Tcm4TlvDq8ikWAM"; // Default: Rachel
|
|
640
|
+
const payload = JSON.stringify({
|
|
641
|
+
text,
|
|
642
|
+
model_id: "eleven_monolingual_v1",
|
|
643
|
+
voice_settings: { stability: 0.5, similarity_boost: 0.5 },
|
|
644
|
+
});
|
|
645
|
+
const options = {
|
|
646
|
+
hostname: "api.elevenlabs.io",
|
|
647
|
+
path: \`/v1/text-to-speech/\${voiceId}\`,
|
|
648
|
+
method: "POST",
|
|
649
|
+
headers: {
|
|
650
|
+
"xi-api-key": apiKey,
|
|
651
|
+
"Content-Type": "application/json",
|
|
652
|
+
Accept: "audio/mpeg",
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
const req = https.request(options, (res) => {
|
|
656
|
+
if (res.statusCode !== 200) {
|
|
657
|
+
reject(new Error(\`ElevenLabs API returned \${res.statusCode}\`));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const out = fs.createWriteStream(outputPath);
|
|
661
|
+
res.pipe(out);
|
|
662
|
+
out.on("finish", () => resolve(outputPath));
|
|
663
|
+
});
|
|
664
|
+
req.on("error", reject);
|
|
665
|
+
req.write(payload);
|
|
666
|
+
req.end();
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function callOpenAITTS(text, voice, outputPath, apiKey) {
|
|
671
|
+
return new Promise((resolve, reject) => {
|
|
672
|
+
const payload = JSON.stringify({
|
|
673
|
+
model: "tts-1",
|
|
674
|
+
input: text,
|
|
675
|
+
voice: voice || "alloy",
|
|
676
|
+
});
|
|
677
|
+
const options = {
|
|
678
|
+
hostname: "api.openai.com",
|
|
679
|
+
path: "/v1/audio/speech",
|
|
680
|
+
method: "POST",
|
|
681
|
+
headers: {
|
|
682
|
+
Authorization: \`Bearer \${apiKey}\`,
|
|
683
|
+
"Content-Type": "application/json",
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
const req = https.request(options, (res) => {
|
|
687
|
+
if (res.statusCode !== 200) {
|
|
688
|
+
reject(new Error(\`OpenAI TTS API returned \${res.statusCode}\`));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const out = fs.createWriteStream(outputPath);
|
|
692
|
+
res.pipe(out);
|
|
693
|
+
out.on("finish", () => resolve(outputPath));
|
|
694
|
+
});
|
|
695
|
+
req.on("error", reject);
|
|
696
|
+
req.write(payload);
|
|
697
|
+
req.end();
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function audioGenHandler(params) {
|
|
702
|
+
const { text, voice, type, output } = params;
|
|
703
|
+
|
|
704
|
+
if (!text) {
|
|
705
|
+
return { error: "Missing required parameter: text" };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const outputPath = output || "output.mp3";
|
|
709
|
+
const outputDir = path.dirname(path.resolve(outputPath));
|
|
710
|
+
if (!fs.existsSync(outputDir)) {
|
|
711
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Try backends in priority order
|
|
715
|
+
const elevenLabsKey = process.env.ELEVENLABS_API_KEY;
|
|
716
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
717
|
+
|
|
718
|
+
// 1. edge-tts (free, no API key needed)
|
|
719
|
+
if (commandExists("edge-tts") || await checkPythonModule("edge_tts")) {
|
|
720
|
+
try {
|
|
721
|
+
await runEdgeTTS(text, voice, outputPath);
|
|
722
|
+
return {
|
|
723
|
+
success: true,
|
|
724
|
+
backend: "edge-tts",
|
|
725
|
+
output: outputPath,
|
|
726
|
+
message: \`Audio saved to \${outputPath} (edge-tts)\`,
|
|
727
|
+
};
|
|
728
|
+
} catch (err) {
|
|
729
|
+
console.warn("[audio-gen] edge-tts failed:", err.message);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// 2. piper-tts (offline, free)
|
|
734
|
+
if (commandExists("piper")) {
|
|
735
|
+
try {
|
|
736
|
+
execSync(\`echo "\${text.replace(/"/g, '\\\\"')}" | piper --output_file "\${outputPath}"\`, {
|
|
737
|
+
encoding: "utf-8",
|
|
738
|
+
});
|
|
739
|
+
return {
|
|
740
|
+
success: true,
|
|
741
|
+
backend: "piper-tts",
|
|
742
|
+
output: outputPath,
|
|
743
|
+
message: \`Audio saved to \${outputPath} (piper-tts)\`,
|
|
744
|
+
};
|
|
745
|
+
} catch (err) {
|
|
746
|
+
console.warn("[audio-gen] piper-tts failed:", err.message);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// 3. ElevenLabs API
|
|
751
|
+
if (elevenLabsKey) {
|
|
752
|
+
try {
|
|
753
|
+
await callElevenLabsAPI(text, voice, outputPath, elevenLabsKey);
|
|
754
|
+
return {
|
|
755
|
+
success: true,
|
|
756
|
+
backend: "elevenlabs",
|
|
757
|
+
output: outputPath,
|
|
758
|
+
message: \`Audio saved to \${outputPath} (ElevenLabs)\`,
|
|
759
|
+
};
|
|
760
|
+
} catch (err) {
|
|
761
|
+
console.warn("[audio-gen] ElevenLabs failed:", err.message);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// 4. OpenAI TTS
|
|
766
|
+
if (openaiKey) {
|
|
767
|
+
try {
|
|
768
|
+
await callOpenAITTS(text, voice, outputPath, openaiKey);
|
|
769
|
+
return {
|
|
770
|
+
success: true,
|
|
771
|
+
backend: "openai-tts",
|
|
772
|
+
output: outputPath,
|
|
773
|
+
message: \`Audio saved to \${outputPath} (OpenAI TTS)\`,
|
|
774
|
+
};
|
|
775
|
+
} catch (err) {
|
|
776
|
+
console.warn("[audio-gen] OpenAI TTS failed:", err.message);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// No backend available
|
|
781
|
+
return {
|
|
782
|
+
error: "No TTS backend available",
|
|
783
|
+
hint: [
|
|
784
|
+
"Install one of the following:",
|
|
785
|
+
" 1. edge-tts (free): pip install edge-tts",
|
|
786
|
+
" 2. piper-tts (offline): pip install piper-tts",
|
|
787
|
+
" 3. ElevenLabs (paid): export ELEVENLABS_API_KEY=<your-key>",
|
|
788
|
+
" 4. OpenAI TTS (paid): export OPENAI_API_KEY=<your-key>",
|
|
789
|
+
].join("\\n"),
|
|
790
|
+
};
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
async function checkPythonModule(moduleName) {
|
|
794
|
+
try {
|
|
795
|
+
execSync(\`python -c "import \${moduleName}"\`, { stdio: "ignore", encoding: "utf-8" });
|
|
796
|
+
return true;
|
|
797
|
+
} catch {
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
audioGenHandler.execute = async (task, _ctx, _skill) => {
|
|
803
|
+
const input = typeof task === "string" ? task : (task.input || task.params?.input || "");
|
|
804
|
+
let p = {};
|
|
805
|
+
try { p = input.trim().startsWith("{") ? JSON.parse(input) : { text: input }; }
|
|
806
|
+
catch { p = { text: input }; }
|
|
807
|
+
return audioGenHandler(p);
|
|
808
|
+
};
|
|
809
|
+
module.exports = audioGenHandler;
|
|
810
|
+
`,
|
|
811
|
+
},
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const WORKFLOW_README = `# ComfyUI Workflows
|
|
815
|
+
|
|
816
|
+
此目录存放 ComfyUI 工作流 JSON 文件。
|
|
817
|
+
|
|
818
|
+
## 如何导出工作流
|
|
819
|
+
|
|
820
|
+
1. 在 ComfyUI 界面中构建你的工作流
|
|
821
|
+
2. 点击菜单 → Save (API Format) 保存为 API 格式 JSON
|
|
822
|
+
3. 将文件保存到此目录
|
|
823
|
+
|
|
824
|
+
## 使用工作流
|
|
825
|
+
|
|
826
|
+
\`\`\`bash
|
|
827
|
+
# 图像生成
|
|
828
|
+
chainlesschain skill run comfyui-image "a sunset over mountains" --args '{"workflow":"workflows/my-image-workflow.json"}'
|
|
829
|
+
|
|
830
|
+
# 视频生成(AnimateDiff)
|
|
831
|
+
chainlesschain skill run comfyui-video "a cat walking" --args '{"workflow":"workflows/my-animatediff-workflow.json","frames":16}'
|
|
832
|
+
\`\`\`
|
|
833
|
+
|
|
834
|
+
## 工作流资源
|
|
835
|
+
|
|
836
|
+
- ComfyUI 官方示例:https://github.com/comfyanonymous/ComfyUI/tree/master/tests/inference
|
|
837
|
+
- AnimateDiff 工作流:https://github.com/guoyww/AnimateDiff-EvolutionaryFramework
|
|
838
|
+
- ComfyUI Manager(管理扩展):https://github.com/ltdrdata/ComfyUI-Manager
|
|
839
|
+
|
|
840
|
+
## cli-anything 集成
|
|
841
|
+
|
|
842
|
+
如果你安装了有 CLI 接口的 AI 工具,可以通过以下命令注册到 ChainlessChain:
|
|
843
|
+
|
|
844
|
+
\`\`\`bash
|
|
845
|
+
# 检查可用的 CLI AI 工具
|
|
846
|
+
chainlesschain cli-anything scan
|
|
847
|
+
|
|
848
|
+
# 注册工具
|
|
849
|
+
chainlesschain cli-anything register <tool-name>
|
|
850
|
+
|
|
851
|
+
# 查看已注册工具
|
|
852
|
+
chainlesschain cli-anything list
|
|
853
|
+
\`\`\`
|
|
854
|
+
|
|
855
|
+
适合通过 cli-anything 注册的工具(有 CLI 接口):
|
|
856
|
+
- FFmpeg(视频处理)
|
|
857
|
+
- yt-dlp(视频下载)
|
|
858
|
+
- audiogen-cli(音频生成 CLI)
|
|
859
|
+
- 第三方 ComfyUI CLI 包装脚本
|
|
860
|
+
`;
|
|
861
|
+
|
|
862
|
+
// Workspace skill templates for ai-doc-creator
|
|
863
|
+
Object.assign(SKILL_TEMPLATES, {
|
|
864
|
+
"doc-generate": {
|
|
865
|
+
md: `---
|
|
866
|
+
name: doc-generate
|
|
867
|
+
display-name: AI 文档生成
|
|
868
|
+
category: document
|
|
869
|
+
description: 利用 AI 生成结构化文档(报告/方案/说明书),支持 Markdown/DOCX/PDF 输出
|
|
870
|
+
version: 1.0.0
|
|
871
|
+
author: ChainlessChain
|
|
872
|
+
parameters:
|
|
873
|
+
- name: topic
|
|
874
|
+
type: string
|
|
875
|
+
required: true
|
|
876
|
+
description: 文档主题或标题
|
|
877
|
+
- name: format
|
|
878
|
+
type: string
|
|
879
|
+
required: false
|
|
880
|
+
description: 输出格式:md(默认)/ docx / pdf / html
|
|
881
|
+
default: "md"
|
|
882
|
+
- name: outline
|
|
883
|
+
type: string
|
|
884
|
+
required: false
|
|
885
|
+
description: 文档大纲(可选,不提供则 AI 自动规划)
|
|
886
|
+
- name: style
|
|
887
|
+
type: string
|
|
888
|
+
required: false
|
|
889
|
+
description: 文档风格:report(报告)/ proposal(方案)/ manual(说明书)/ readme(README)
|
|
890
|
+
default: "report"
|
|
891
|
+
- name: output
|
|
892
|
+
type: string
|
|
893
|
+
required: false
|
|
894
|
+
description: 输出文件名(不含扩展名,默认为主题名)
|
|
895
|
+
execution-mode: direct
|
|
896
|
+
---
|
|
897
|
+
|
|
898
|
+
# AI 文档生成
|
|
899
|
+
|
|
900
|
+
利用 AI 生成结构化文档,支持多种格式输出。
|
|
901
|
+
|
|
902
|
+
## 使用示例
|
|
903
|
+
|
|
904
|
+
\`\`\`bash
|
|
905
|
+
# 生成 Markdown 报告
|
|
906
|
+
chainlesschain skill run doc-generate "2026年AI技术趋势分析报告"
|
|
907
|
+
|
|
908
|
+
# 生成方案文档(指定风格)
|
|
909
|
+
chainlesschain skill run doc-generate "电商平台重构方案" --args '{"style":"proposal","format":"md"}'
|
|
910
|
+
|
|
911
|
+
# 生成 DOCX(需要 LibreOffice 或 pandoc)
|
|
912
|
+
chainlesschain skill run doc-generate "产品需求说明书" --args '{"format":"docx","style":"manual"}'
|
|
913
|
+
|
|
914
|
+
# 提供大纲(精准控制结构)
|
|
915
|
+
chainlesschain skill run doc-generate "API文档" --args '{"outline":"1.概述 2.认证方式 3.接口列表 4.错误码","format":"md"}'
|
|
916
|
+
\`\`\`
|
|
917
|
+
|
|
918
|
+
## 格式转换依赖
|
|
919
|
+
|
|
920
|
+
| 输出格式 | 依赖 | 安装方式 |
|
|
921
|
+
|---------|------|---------|
|
|
922
|
+
| md | 无 | 内置 |
|
|
923
|
+
| html | 无 | 内置(markdown 渲染) |
|
|
924
|
+
| docx | pandoc 或 LibreOffice | 见下方 |
|
|
925
|
+
| pdf | LibreOffice | \`soffice --headless\` |
|
|
926
|
+
|
|
927
|
+
\`\`\`bash
|
|
928
|
+
# 安装 pandoc(推荐,md→docx 最佳)
|
|
929
|
+
# Windows: winget install pandoc 或 choco install pandoc
|
|
930
|
+
# macOS: brew install pandoc
|
|
931
|
+
# Linux: apt install pandoc
|
|
932
|
+
|
|
933
|
+
# LibreOffice(全格式转换)
|
|
934
|
+
# Windows: winget install LibreOffice.LibreOffice
|
|
935
|
+
# macOS: brew install --cask libreoffice
|
|
936
|
+
# Linux: apt install libreoffice
|
|
937
|
+
\`\`\`
|
|
938
|
+
|
|
939
|
+
## cli-anything 集成说明
|
|
940
|
+
|
|
941
|
+
LibreOffice 具有完整的 CLI 接口(\`soffice --headless\`),可以通过 cli-anything 注册以获得更直接的访问方式:
|
|
942
|
+
|
|
943
|
+
\`\`\`bash
|
|
944
|
+
# 注册 LibreOffice 为独立技能
|
|
945
|
+
chainlesschain cli-anything register soffice
|
|
946
|
+
# 或
|
|
947
|
+
chainlesschain cli-anything register libreoffice
|
|
948
|
+
|
|
949
|
+
# 注册后可以直接调用 LibreOffice 任意子命令
|
|
950
|
+
chainlesschain skill run cli-anything-soffice "convert report.md to pdf"
|
|
951
|
+
\`\`\`
|
|
952
|
+
|
|
953
|
+
> 建议:日常 AI 文档生成使用 \`doc-generate\` 技能;需要直接操作 LibreOffice 高级功能(宏、模板、样式)时,通过 cli-anything 注册 \`soffice\` 获得更大灵活性。
|
|
954
|
+
`,
|
|
955
|
+
handler: `/**
|
|
956
|
+
* AI Document Generation Skill Handler
|
|
957
|
+
* Generates structured documents using the local LLM (via chainlesschain ask),
|
|
958
|
+
* then optionally converts to DOCX/PDF using pandoc or LibreOffice.
|
|
959
|
+
*
|
|
960
|
+
* Requirements for format conversion:
|
|
961
|
+
* - md/html: built-in (no dependencies)
|
|
962
|
+
* - docx: pandoc (preferred) or LibreOffice soffice --headless
|
|
963
|
+
* - pdf: LibreOffice soffice --headless
|
|
964
|
+
*/
|
|
965
|
+
|
|
966
|
+
const { spawnSync, execSync } = require("child_process");
|
|
967
|
+
const fs = require("fs");
|
|
968
|
+
const path = require("path");
|
|
969
|
+
|
|
970
|
+
// ─── Tool detection ───────────────────────────────────────────────
|
|
971
|
+
|
|
972
|
+
function commandExists(cmd) {
|
|
973
|
+
try {
|
|
974
|
+
execSync(\`\${cmd} --version\`, { stdio: "ignore", encoding: "utf-8" });
|
|
975
|
+
return true;
|
|
976
|
+
} catch {
|
|
977
|
+
return false;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function detectSoffice() {
|
|
982
|
+
if (commandExists("soffice")) return "soffice";
|
|
983
|
+
if (commandExists("libreoffice")) return "libreoffice";
|
|
984
|
+
// Windows default install path
|
|
985
|
+
const winPath = "C:\\\\Program Files\\\\LibreOffice\\\\program\\\\soffice.exe";
|
|
986
|
+
if (process.platform === "win32" && fs.existsSync(winPath)) return \`"\${winPath}"\`;
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ─── Document style prompts ───────────────────────────────────────
|
|
991
|
+
|
|
992
|
+
const STYLE_PROMPTS = {
|
|
993
|
+
report: "一份专业的分析报告,包含执行摘要、背景、详细分析、结论和建议",
|
|
994
|
+
proposal: "一份详细的项目方案,包含项目背景、目标、实施方案、资源需求、风险分析和预期收益",
|
|
995
|
+
manual: "一份完整的说明书/手册,包含概述、安装/配置步骤、功能说明、常见问题和参考资料",
|
|
996
|
+
readme: "一份标准的 README 文档,包含项目简介、快速开始、功能特性、安装使用、贡献指南和许可证",
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
// ─── Markdown to HTML ─────────────────────────────────────────────
|
|
1000
|
+
|
|
1001
|
+
function mdToHtml(md, title) {
|
|
1002
|
+
return \`<!DOCTYPE html>
|
|
1003
|
+
<html lang="zh-CN">
|
|
1004
|
+
<head>
|
|
1005
|
+
<meta charset="UTF-8">
|
|
1006
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1007
|
+
<title>\${title}</title>
|
|
1008
|
+
<style>
|
|
1009
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1010
|
+
max-width: 900px; margin: 0 auto; padding: 40px 20px; line-height: 1.6; }
|
|
1011
|
+
h1, h2, h3 { border-bottom: 1px solid #eee; padding-bottom: 8px; }
|
|
1012
|
+
pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow: auto; }
|
|
1013
|
+
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
|
|
1014
|
+
table { border-collapse: collapse; width: 100%; }
|
|
1015
|
+
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
|
1016
|
+
th { background: #f6f8fa; }
|
|
1017
|
+
</style>
|
|
1018
|
+
</head>
|
|
1019
|
+
<body>
|
|
1020
|
+
\${md
|
|
1021
|
+
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
|
|
1022
|
+
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
|
|
1023
|
+
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
|
|
1024
|
+
.replace(/\\*\\*(.+?)\\*\\*/g, "<strong>$1</strong>")
|
|
1025
|
+
.replace(/\\*(.+?)\\*/g, "<em>$1</em>")
|
|
1026
|
+
.replace(/\`(.+?)\`/g, "<code>$1</code>")
|
|
1027
|
+
.replace(/^- (.+)$/gm, "<li>$1</li>")
|
|
1028
|
+
.replace(/(<li>.*<\\/li>\\n?)+/g, (m) => "<ul>" + m + "</ul>")
|
|
1029
|
+
.replace(/^\\d+\\. (.+)$/gm, "<li>$1</li>")
|
|
1030
|
+
.replace(/\\n\\n/g, "</p><p>")
|
|
1031
|
+
.replace(/^(?!<[h|u|o|l|p|t])/gm, "<p>")
|
|
1032
|
+
}
|
|
1033
|
+
</body>
|
|
1034
|
+
</html>\`;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ─── Conversion helpers ───────────────────────────────────────────
|
|
1038
|
+
|
|
1039
|
+
function convertWithPandoc(mdFile, outputFile, format) {
|
|
1040
|
+
const result = spawnSync(
|
|
1041
|
+
"pandoc",
|
|
1042
|
+
[mdFile, "-o", outputFile, "--standalone"],
|
|
1043
|
+
{ encoding: "utf-8", timeout: 60000 },
|
|
1044
|
+
);
|
|
1045
|
+
if (result.status !== 0) {
|
|
1046
|
+
throw new Error(\`pandoc failed: \${result.stderr || result.error?.message}\`);
|
|
1047
|
+
}
|
|
1048
|
+
return outputFile;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function convertWithSoffice(sofficeCmd, inputFile, format, outDir) {
|
|
1052
|
+
const result = spawnSync(
|
|
1053
|
+
sofficeCmd,
|
|
1054
|
+
["--headless", "--convert-to", format, inputFile, "--outdir", outDir],
|
|
1055
|
+
{ encoding: "utf-8", timeout: 120000, shell: process.platform === "win32" },
|
|
1056
|
+
);
|
|
1057
|
+
if (result.status !== 0) {
|
|
1058
|
+
throw new Error(\`soffice failed: \${result.stderr || result.error?.message}\`);
|
|
1059
|
+
}
|
|
1060
|
+
// soffice outputs to <basename>.<format> in outDir
|
|
1061
|
+
const base = path.basename(inputFile, path.extname(inputFile));
|
|
1062
|
+
return path.join(outDir, \`\${base}.\${format}\`);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ─── LLM content generation ───────────────────────────────────────
|
|
1066
|
+
|
|
1067
|
+
function generateContent(topic, style, outline) {
|
|
1068
|
+
const styleDesc = STYLE_PROMPTS[style] || STYLE_PROMPTS.report;
|
|
1069
|
+
const outlineSection = outline
|
|
1070
|
+
? \`\\n\\n大纲要求(请按以下结构组织内容):\\n\${outline}\`
|
|
1071
|
+
: "";
|
|
1072
|
+
|
|
1073
|
+
const prompt =
|
|
1074
|
+
\`请为以下主题生成\${styleDesc}。\\n\\n主题:\${topic}\${outlineSection}\\n\\n要求:\\n- 使用标准 Markdown 格式(H1/H2/H3 标题、列表、表格等)\\n- 内容详实具体,不少于 800 字\\n- 使用中文撰写\\n- 直接输出 Markdown 内容,不需要额外说明\`;
|
|
1075
|
+
|
|
1076
|
+
// Try chainlesschain ask first
|
|
1077
|
+
const askResult = spawnSync(
|
|
1078
|
+
process.execPath,
|
|
1079
|
+
[process.argv[1], "ask", prompt],
|
|
1080
|
+
{
|
|
1081
|
+
encoding: "utf-8",
|
|
1082
|
+
timeout: 120000,
|
|
1083
|
+
cwd: process.cwd(),
|
|
1084
|
+
env: process.env,
|
|
1085
|
+
},
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
if (askResult.status === 0 && askResult.stdout && askResult.stdout.trim().length > 100) {
|
|
1089
|
+
return askResult.stdout.trim();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Fallback: return a structured template that user can fill
|
|
1093
|
+
return \`# \${topic}
|
|
1094
|
+
|
|
1095
|
+
> *本文档由 AI 文档生成技能创建。LLM 调用失败,请手动完善内容,或确认 chainlesschain ask 命令可用。*
|
|
1096
|
+
|
|
1097
|
+
## 概述
|
|
1098
|
+
|
|
1099
|
+
[在此填写 \${topic} 的概述内容]
|
|
1100
|
+
|
|
1101
|
+
## 背景与目标
|
|
1102
|
+
|
|
1103
|
+
[在此填写背景信息和文档目标]
|
|
1104
|
+
|
|
1105
|
+
## 详细内容
|
|
1106
|
+
|
|
1107
|
+
### 一、[主要章节]
|
|
1108
|
+
|
|
1109
|
+
[在此填写主要内容]
|
|
1110
|
+
|
|
1111
|
+
### 二、[次要章节]
|
|
1112
|
+
|
|
1113
|
+
[在此填写次要内容]
|
|
1114
|
+
|
|
1115
|
+
## 结论与建议
|
|
1116
|
+
|
|
1117
|
+
[在此填写结论]
|
|
1118
|
+
|
|
1119
|
+
## 附录
|
|
1120
|
+
|
|
1121
|
+
[在此填写附录内容(如有)]
|
|
1122
|
+
\`;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// ─── Main handler ─────────────────────────────────────────────────
|
|
1126
|
+
|
|
1127
|
+
async function docGenerateHandler(params) {
|
|
1128
|
+
const { topic, format, outline, style, output } = params;
|
|
1129
|
+
|
|
1130
|
+
if (!topic) {
|
|
1131
|
+
return { error: "Missing required parameter: topic", hint: "chainlesschain skill run doc-generate \\"文档主题\\"" };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const fmt = (format || "md").toLowerCase();
|
|
1135
|
+
const docStyle = style || "report";
|
|
1136
|
+
const safeTitle = (output || topic).replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, "_").slice(0, 50);
|
|
1137
|
+
const outputDir = process.cwd();
|
|
1138
|
+
|
|
1139
|
+
// Generate content via LLM
|
|
1140
|
+
console.log(\`[doc-generate] Generating \${docStyle} document for: \${topic}\`);
|
|
1141
|
+
const mdContent = generateContent(topic, docStyle, outline);
|
|
1142
|
+
|
|
1143
|
+
// Always write markdown first
|
|
1144
|
+
const mdFile = path.join(outputDir, \`\${safeTitle}.md\`);
|
|
1145
|
+
fs.writeFileSync(mdFile, mdContent, "utf-8");
|
|
1146
|
+
console.log(\`[doc-generate] Markdown saved: \${mdFile}\`);
|
|
1147
|
+
|
|
1148
|
+
if (fmt === "md") {
|
|
1149
|
+
return {
|
|
1150
|
+
success: true,
|
|
1151
|
+
format: "md",
|
|
1152
|
+
output: mdFile,
|
|
1153
|
+
message: \`Markdown document saved to: \${mdFile}\`,
|
|
1154
|
+
wordCount: mdContent.split(/\\s+/).length,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (fmt === "html") {
|
|
1159
|
+
const htmlFile = path.join(outputDir, \`\${safeTitle}.html\`);
|
|
1160
|
+
fs.writeFileSync(htmlFile, mdToHtml(mdContent, topic), "utf-8");
|
|
1161
|
+
return {
|
|
1162
|
+
success: true,
|
|
1163
|
+
format: "html",
|
|
1164
|
+
output: htmlFile,
|
|
1165
|
+
mdOutput: mdFile,
|
|
1166
|
+
message: \`HTML document saved to: \${htmlFile}\`,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// DOCX / PDF: try pandoc then soffice
|
|
1171
|
+
const sofficeCmd = detectSoffice();
|
|
1172
|
+
|
|
1173
|
+
if (fmt === "docx") {
|
|
1174
|
+
if (commandExists("pandoc")) {
|
|
1175
|
+
try {
|
|
1176
|
+
const docxFile = path.join(outputDir, \`\${safeTitle}.docx\`);
|
|
1177
|
+
convertWithPandoc(mdFile, docxFile, "docx");
|
|
1178
|
+
return { success: true, format: "docx", output: docxFile, mdOutput: mdFile, message: \`DOCX saved to: \${docxFile} (via pandoc)\` };
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
console.warn("[doc-generate] pandoc failed:", err.message);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (sofficeCmd) {
|
|
1184
|
+
try {
|
|
1185
|
+
// soffice needs HTML or ODT as input for md→docx (md not natively supported)
|
|
1186
|
+
const htmlFile = path.join(outputDir, \`\${safeTitle}.html\`);
|
|
1187
|
+
fs.writeFileSync(htmlFile, mdToHtml(mdContent, topic), "utf-8");
|
|
1188
|
+
const docxFile = convertWithSoffice(sofficeCmd, htmlFile, "docx", outputDir);
|
|
1189
|
+
// Clean up temp HTML
|
|
1190
|
+
try { fs.unlinkSync(htmlFile); } catch { /* ignore */ }
|
|
1191
|
+
return { success: true, format: "docx", output: docxFile, mdOutput: mdFile, message: \`DOCX saved to: \${docxFile} (via LibreOffice)\` };
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
console.warn("[doc-generate] soffice docx failed:", err.message);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return {
|
|
1197
|
+
success: true,
|
|
1198
|
+
format: "md",
|
|
1199
|
+
output: mdFile,
|
|
1200
|
+
message: \`Markdown saved (DOCX conversion requires pandoc or LibreOffice — neither found)\`,
|
|
1201
|
+
hint: "Install: winget install pandoc OR apt install pandoc OR apt install libreoffice",
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (fmt === "pdf") {
|
|
1206
|
+
if (sofficeCmd) {
|
|
1207
|
+
try {
|
|
1208
|
+
const htmlFile = path.join(outputDir, \`\${safeTitle}.html\`);
|
|
1209
|
+
fs.writeFileSync(htmlFile, mdToHtml(mdContent, topic), "utf-8");
|
|
1210
|
+
const pdfFile = convertWithSoffice(sofficeCmd, htmlFile, "pdf", outputDir);
|
|
1211
|
+
try { fs.unlinkSync(htmlFile); } catch { /* ignore */ }
|
|
1212
|
+
return { success: true, format: "pdf", output: pdfFile, mdOutput: mdFile, message: \`PDF saved to: \${pdfFile} (via LibreOffice)\` };
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
console.warn("[doc-generate] soffice pdf failed:", err.message);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (commandExists("pandoc") && commandExists("wkhtmltopdf")) {
|
|
1218
|
+
try {
|
|
1219
|
+
const pdfFile = path.join(outputDir, \`\${safeTitle}.pdf\`);
|
|
1220
|
+
convertWithPandoc(mdFile, pdfFile, "pdf");
|
|
1221
|
+
return { success: true, format: "pdf", output: pdfFile, mdOutput: mdFile, message: \`PDF saved to: \${pdfFile} (via pandoc+wkhtmltopdf)\` };
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
console.warn("[doc-generate] pandoc pdf failed:", err.message);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return {
|
|
1227
|
+
success: true,
|
|
1228
|
+
format: "md",
|
|
1229
|
+
output: mdFile,
|
|
1230
|
+
message: \`Markdown saved (PDF conversion requires LibreOffice — not found)\`,
|
|
1231
|
+
hint: "Install: apt install libreoffice OR winget install LibreOffice.LibreOffice",
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
return { error: \`Unsupported format: \${fmt}\`, hint: "Supported formats: md, html, docx, pdf" };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
docGenerateHandler.execute = async (task, _ctx, _skill) => {
|
|
1239
|
+
const input = typeof task === "string" ? task : (task.input || task.params?.input || "");
|
|
1240
|
+
let p = {};
|
|
1241
|
+
try { p = input.trim().startsWith("{") ? JSON.parse(input) : { topic: input }; }
|
|
1242
|
+
catch { p = { topic: input }; }
|
|
1243
|
+
return docGenerateHandler(p);
|
|
1244
|
+
};
|
|
1245
|
+
module.exports = docGenerateHandler;
|
|
1246
|
+
`,
|
|
1247
|
+
},
|
|
1248
|
+
|
|
1249
|
+
"libre-convert": {
|
|
1250
|
+
md: `---
|
|
1251
|
+
name: libre-convert
|
|
1252
|
+
display-name: LibreOffice 文档转换
|
|
1253
|
+
category: document
|
|
1254
|
+
description: 使用 LibreOffice(soffice --headless)在 docx/pdf/html/odt/pptx 等格式之间转换文档
|
|
1255
|
+
version: 1.0.0
|
|
1256
|
+
author: ChainlessChain
|
|
1257
|
+
parameters:
|
|
1258
|
+
- name: input_file
|
|
1259
|
+
type: string
|
|
1260
|
+
required: true
|
|
1261
|
+
description: 要转换的源文件路径
|
|
1262
|
+
- name: format
|
|
1263
|
+
type: string
|
|
1264
|
+
required: false
|
|
1265
|
+
description: 目标格式:pdf / docx / html / odt / pptx / xlsx / png
|
|
1266
|
+
default: "pdf"
|
|
1267
|
+
- name: outdir
|
|
1268
|
+
type: string
|
|
1269
|
+
required: false
|
|
1270
|
+
description: 输出目录(默认与源文件相同目录)
|
|
1271
|
+
execution-mode: direct
|
|
1272
|
+
---
|
|
1273
|
+
|
|
1274
|
+
# LibreOffice 文档格式转换
|
|
1275
|
+
|
|
1276
|
+
使用 LibreOffice 的无头模式(\`soffice --headless\`)在各种办公文档格式之间转换。
|
|
1277
|
+
|
|
1278
|
+
## 支持的转换格式
|
|
1279
|
+
|
|
1280
|
+
| 源格式 | → 目标格式 |
|
|
1281
|
+
|--------|-----------|
|
|
1282
|
+
| docx / doc | pdf, odt, html |
|
|
1283
|
+
| odt | pdf, docx, html |
|
|
1284
|
+
| pptx / ppt | pdf, html, png |
|
|
1285
|
+
| xlsx / xls | pdf, csv, html |
|
|
1286
|
+
| html / md | pdf, docx, odt |
|
|
1287
|
+
| jpg / png | pdf |
|
|
1288
|
+
|
|
1289
|
+
## 使用示例
|
|
1290
|
+
|
|
1291
|
+
\`\`\`bash
|
|
1292
|
+
# Word 文档转 PDF
|
|
1293
|
+
chainlesschain skill run libre-convert "report.docx"
|
|
1294
|
+
|
|
1295
|
+
# 指定格式
|
|
1296
|
+
chainlesschain skill run libre-convert "slides.pptx" --args '{"format":"pdf"}'
|
|
1297
|
+
|
|
1298
|
+
# 指定输出目录
|
|
1299
|
+
chainlesschain skill run libre-convert "contract.docx" --args '{"format":"pdf","outdir":"./output"}'
|
|
1300
|
+
|
|
1301
|
+
# 批量转换(在 agent 模式下)
|
|
1302
|
+
chainlesschain agent
|
|
1303
|
+
# > 将 docs/ 目录下所有 docx 文件转换为 PDF
|
|
1304
|
+
\`\`\`
|
|
1305
|
+
|
|
1306
|
+
## cli-anything 集成
|
|
1307
|
+
|
|
1308
|
+
LibreOffice 具有完整的 CLI 接口,除了使用此技能外,还可以通过 cli-anything 将完整的 \`soffice\` 命令行注册为技能:
|
|
1309
|
+
|
|
1310
|
+
\`\`\`bash
|
|
1311
|
+
# 注册完整的 soffice CLI(支持 LibreOffice 所有子命令)
|
|
1312
|
+
chainlesschain cli-anything register soffice
|
|
1313
|
+
|
|
1314
|
+
# 注册后可访问 LibreOffice 所有功能(宏执行、模板、高级格式设置等)
|
|
1315
|
+
chainlesschain skill run cli-anything-soffice "convert report.docx --format pdf"
|
|
1316
|
+
\`\`\`
|
|
1317
|
+
|
|
1318
|
+
## 前提条件
|
|
1319
|
+
|
|
1320
|
+
需要安装 LibreOffice:
|
|
1321
|
+
|
|
1322
|
+
\`\`\`bash
|
|
1323
|
+
# Windows
|
|
1324
|
+
winget install LibreOffice.LibreOffice
|
|
1325
|
+
|
|
1326
|
+
# macOS
|
|
1327
|
+
brew install --cask libreoffice
|
|
1328
|
+
|
|
1329
|
+
# Ubuntu/Debian
|
|
1330
|
+
sudo apt install libreoffice
|
|
1331
|
+
|
|
1332
|
+
# 验证安装
|
|
1333
|
+
soffice --version
|
|
1334
|
+
\`\`\`
|
|
1335
|
+
`,
|
|
1336
|
+
handler: `/**
|
|
1337
|
+
* LibreOffice Document Conversion Skill Handler
|
|
1338
|
+
* Uses soffice --headless to convert between document formats.
|
|
1339
|
+
*
|
|
1340
|
+
* Requirements: LibreOffice installed (soffice or libreoffice in PATH,
|
|
1341
|
+
* or Windows default installation path)
|
|
1342
|
+
*
|
|
1343
|
+
* LibreOffice CLI vs cli-anything:
|
|
1344
|
+
* - This skill: embedded wrapper for common conversion use cases
|
|
1345
|
+
* - cli-anything: register soffice for full LibreOffice CLI access
|
|
1346
|
+
* chainlesschain cli-anything register soffice
|
|
1347
|
+
*/
|
|
1348
|
+
|
|
1349
|
+
const { spawnSync, execSync } = require("child_process");
|
|
1350
|
+
const fs = require("fs");
|
|
1351
|
+
const path = require("path");
|
|
1352
|
+
|
|
1353
|
+
const SUPPORTED_FORMATS = new Set(["pdf", "docx", "html", "odt", "pptx", "xlsx", "csv", "txt", "png"]);
|
|
1354
|
+
|
|
1355
|
+
function findSoffice() {
|
|
1356
|
+
const candidates = ["soffice", "libreoffice"];
|
|
1357
|
+
for (const cmd of candidates) {
|
|
1358
|
+
try {
|
|
1359
|
+
execSync(\`\${cmd} --version\`, { stdio: "ignore", encoding: "utf-8" });
|
|
1360
|
+
return cmd;
|
|
1361
|
+
} catch { /* try next */ }
|
|
1362
|
+
}
|
|
1363
|
+
// Windows default installation paths
|
|
1364
|
+
if (process.platform === "win32") {
|
|
1365
|
+
const paths = [
|
|
1366
|
+
"C:\\\\Program Files\\\\LibreOffice\\\\program\\\\soffice.exe",
|
|
1367
|
+
"C:\\\\Program Files (x86)\\\\LibreOffice\\\\program\\\\soffice.exe",
|
|
1368
|
+
];
|
|
1369
|
+
for (const p of paths) {
|
|
1370
|
+
if (fs.existsSync(p)) return \`"\${p}"\`;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return null;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
async function libreConvertHandler(params) {
|
|
1377
|
+
const { input_file, format, outdir } = params;
|
|
1378
|
+
|
|
1379
|
+
if (!input_file) {
|
|
1380
|
+
return { error: "Missing required parameter: input_file" };
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
const targetFormat = (format || "pdf").toLowerCase();
|
|
1384
|
+
if (!SUPPORTED_FORMATS.has(targetFormat)) {
|
|
1385
|
+
return {
|
|
1386
|
+
error: \`Unsupported format: \${targetFormat}\`,
|
|
1387
|
+
hint: \`Supported formats: \${[...SUPPORTED_FORMATS].join(", ")}\`,
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Resolve input file path
|
|
1392
|
+
const inputPath = path.isAbsolute(input_file)
|
|
1393
|
+
? input_file
|
|
1394
|
+
: path.join(process.cwd(), input_file);
|
|
1395
|
+
|
|
1396
|
+
if (!fs.existsSync(inputPath)) {
|
|
1397
|
+
return { error: \`Input file not found: \${inputPath}\` };
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Find LibreOffice
|
|
1401
|
+
const sofficeCmd = findSoffice();
|
|
1402
|
+
if (!sofficeCmd) {
|
|
1403
|
+
return {
|
|
1404
|
+
error: "LibreOffice not found",
|
|
1405
|
+
hint: [
|
|
1406
|
+
"Install LibreOffice to use this skill:",
|
|
1407
|
+
" Windows: winget install LibreOffice.LibreOffice",
|
|
1408
|
+
" macOS: brew install --cask libreoffice",
|
|
1409
|
+
" Linux: sudo apt install libreoffice",
|
|
1410
|
+
"",
|
|
1411
|
+
"Alternatively, register soffice via cli-anything for full access:",
|
|
1412
|
+
" chainlesschain cli-anything register soffice",
|
|
1413
|
+
].join("\\n"),
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Determine output directory
|
|
1418
|
+
const outputDir = outdir
|
|
1419
|
+
? (path.isAbsolute(outdir) ? outdir : path.join(process.cwd(), outdir))
|
|
1420
|
+
: path.dirname(inputPath);
|
|
1421
|
+
|
|
1422
|
+
if (!fs.existsSync(outputDir)) {
|
|
1423
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
console.log(\`[libre-convert] Converting \${path.basename(inputPath)} → \${targetFormat}\`);
|
|
1427
|
+
|
|
1428
|
+
// Run soffice
|
|
1429
|
+
const result = spawnSync(
|
|
1430
|
+
sofficeCmd,
|
|
1431
|
+
["--headless", "--convert-to", targetFormat, inputPath, "--outdir", outputDir],
|
|
1432
|
+
{
|
|
1433
|
+
encoding: "utf-8",
|
|
1434
|
+
timeout: 120000,
|
|
1435
|
+
shell: process.platform === "win32",
|
|
1436
|
+
},
|
|
1437
|
+
);
|
|
1438
|
+
|
|
1439
|
+
if (result.error) {
|
|
1440
|
+
return { error: \`Failed to launch LibreOffice: \${result.error.message}\` };
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (result.status !== 0) {
|
|
1444
|
+
return {
|
|
1445
|
+
error: \`LibreOffice conversion failed (exit \${result.status})\`,
|
|
1446
|
+
stderr: result.stderr,
|
|
1447
|
+
hint: "Check if the input file is valid and not password-protected",
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// Find the output file
|
|
1452
|
+
const baseName = path.basename(inputPath, path.extname(inputPath));
|
|
1453
|
+
const outputFile = path.join(outputDir, \`\${baseName}.\${targetFormat}\`);
|
|
1454
|
+
|
|
1455
|
+
if (!fs.existsSync(outputFile)) {
|
|
1456
|
+
// soffice may use a slightly different output name — scan dir
|
|
1457
|
+
const files = fs.readdirSync(outputDir).filter(
|
|
1458
|
+
(f) => f.startsWith(baseName) && f.endsWith(\`.\${targetFormat}\`),
|
|
1459
|
+
);
|
|
1460
|
+
if (files.length > 0) {
|
|
1461
|
+
return {
|
|
1462
|
+
success: true,
|
|
1463
|
+
output: path.join(outputDir, files[0]),
|
|
1464
|
+
message: \`Converted to \${targetFormat}: \${files[0]}\`,
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
return {
|
|
1468
|
+
error: "Conversion completed but output file not found",
|
|
1469
|
+
stdout: result.stdout,
|
|
1470
|
+
hint: \`Check \${outputDir} for the converted file\`,
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
return {
|
|
1475
|
+
success: true,
|
|
1476
|
+
input: inputPath,
|
|
1477
|
+
output: outputFile,
|
|
1478
|
+
format: targetFormat,
|
|
1479
|
+
message: \`Converted to \${targetFormat}: \${outputFile}\`,
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
libreConvertHandler.execute = async (task, _ctx, _skill) => {
|
|
1484
|
+
const input = typeof task === "string" ? task : (task.input || task.params?.input || "");
|
|
1485
|
+
let p = {};
|
|
1486
|
+
try { p = input.trim().startsWith("{") ? JSON.parse(input) : { input_file: input }; }
|
|
1487
|
+
catch { p = { input_file: input }; }
|
|
1488
|
+
return libreConvertHandler(p);
|
|
1489
|
+
};
|
|
1490
|
+
module.exports = libreConvertHandler;
|
|
1491
|
+
`,
|
|
1492
|
+
},
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// Workspace skill templates for doc-edit (ai-doc-creator extension)
|
|
1496
|
+
Object.assign(SKILL_TEMPLATES, {
|
|
1497
|
+
"doc-edit": {
|
|
1498
|
+
md: `---
|
|
1499
|
+
name: doc-edit
|
|
1500
|
+
display-name: AI 文档修改
|
|
1501
|
+
category: document
|
|
1502
|
+
description: 使用 AI 修改现有文档内容,保留原始格式与结构(公式/图表/样式不丢失)
|
|
1503
|
+
version: 1.0.0
|
|
1504
|
+
author: ChainlessChain
|
|
1505
|
+
input_schema:
|
|
1506
|
+
type: object
|
|
1507
|
+
properties:
|
|
1508
|
+
input_file:
|
|
1509
|
+
type: string
|
|
1510
|
+
description: 要修改的文件路径(支持 md/txt/html/docx/xlsx/pptx)
|
|
1511
|
+
instruction:
|
|
1512
|
+
type: string
|
|
1513
|
+
description: 修改指令,如"将所有'项目'替换为'工程'并优化摘要部分"
|
|
1514
|
+
action:
|
|
1515
|
+
type: string
|
|
1516
|
+
enum: [edit, append, rewrite-section]
|
|
1517
|
+
default: edit
|
|
1518
|
+
description: 操作模式(edit=全文修改, append=追加章节, rewrite-section=重写指定标题)
|
|
1519
|
+
section:
|
|
1520
|
+
type: string
|
|
1521
|
+
description: rewrite-section 时指定目标标题名称
|
|
1522
|
+
output_dir:
|
|
1523
|
+
type: string
|
|
1524
|
+
description: 输出目录(默认与输入文件同目录)
|
|
1525
|
+
required: [input_file, instruction]
|
|
1526
|
+
execution-mode: direct
|
|
1527
|
+
---
|
|
1528
|
+
|
|
1529
|
+
# AI 文档修改 (doc-edit)
|
|
1530
|
+
|
|
1531
|
+
使用 AI 对现有文档进行智能修改,**保留原始格式与结构**(公式、图表、样式、动画不丢失)。
|
|
1532
|
+
|
|
1533
|
+
## 支持格式
|
|
1534
|
+
|
|
1535
|
+
| 格式 | 处理方式 | 结构保留 |
|
|
1536
|
+
|------|----------|----------|
|
|
1537
|
+
| md / txt / html | 直接文本修改 | 完全保留 |
|
|
1538
|
+
| docx | pandoc 往返转换(优先)/ soffice 回退 | 基本保留 |
|
|
1539
|
+
| xlsx | Python + openpyxl(保留公式/图表) | 公式以字符串保存,完全保留 |
|
|
1540
|
+
| pptx | Python + python-pptx(只改文本 run) | 图表/图片/动画完全不碰 |
|
|
1541
|
+
|
|
1542
|
+
## 使用示例
|
|
1543
|
+
|
|
1544
|
+
\`\`\`bash
|
|
1545
|
+
# 修改 Markdown 文档
|
|
1546
|
+
chainlesschain skill run doc-edit --args '{"input_file":"report.md","instruction":"优化摘要部分,使语气更正式"}'
|
|
1547
|
+
|
|
1548
|
+
# 追加新章节
|
|
1549
|
+
chainlesschain skill run doc-edit --args '{"input_file":"report.md","instruction":"添加结论章节,总结主要发现","action":"append"}'
|
|
1550
|
+
|
|
1551
|
+
# 重写特定标题
|
|
1552
|
+
chainlesschain skill run doc-edit --args '{"input_file":"spec.md","instruction":"重写为更技术性的描述","action":"rewrite-section","section":"架构设计"}'
|
|
1553
|
+
|
|
1554
|
+
# 修改 Excel(保留公式)
|
|
1555
|
+
chainlesschain skill run doc-edit --args '{"input_file":"data.xlsx","instruction":"将所有产品名称首字母大写"}'
|
|
1556
|
+
|
|
1557
|
+
# 修改 PowerPoint(保留图表/动画)
|
|
1558
|
+
chainlesschain skill run doc-edit --args '{"input_file":"slides.pptx","instruction":"将语气改为更正式的商业风格"}'
|
|
1559
|
+
\`\`\`
|
|
1560
|
+
|
|
1561
|
+
## 输出命名规则
|
|
1562
|
+
|
|
1563
|
+
输出文件命名为 \`{原始文件名}_edited.{扩展名}\`,**永不覆盖原文件**。
|
|
1564
|
+
|
|
1565
|
+
## 前提条件
|
|
1566
|
+
|
|
1567
|
+
- **md/txt/html**:无需额外工具
|
|
1568
|
+
- **docx**:pandoc(\`winget install pandoc\`)或 LibreOffice(\`winget install LibreOffice.LibreOffice\`)
|
|
1569
|
+
- **xlsx**:Python 3.x + openpyxl(\`pip install openpyxl\`)
|
|
1570
|
+
- **pptx**:Python 3.x + python-pptx(\`pip install python-pptx\`)
|
|
1571
|
+
|
|
1572
|
+
## cli-anything 集成
|
|
1573
|
+
|
|
1574
|
+
本技能通过内联 Python 子进程处理 xlsx/pptx,无需注册 cli-anything。
|
|
1575
|
+
对于需要更精细控制的场景(如 VBA 宏、复杂图表编辑),可注册 soffice:
|
|
1576
|
+
|
|
1577
|
+
\`\`\`bash
|
|
1578
|
+
chainlesschain cli-anything register soffice
|
|
1579
|
+
\`\`\`
|
|
1580
|
+
`,
|
|
1581
|
+
handler: `/**
|
|
1582
|
+
* doc-edit Skill Handler
|
|
1583
|
+
* Edits existing documents using AI, preserving formulas/charts/styles.
|
|
1584
|
+
*
|
|
1585
|
+
* Supported formats:
|
|
1586
|
+
* md/txt/html — direct text edit via LLM
|
|
1587
|
+
* docx — pandoc round-trip (fallback: soffice)
|
|
1588
|
+
* xlsx — Python + openpyxl (preserves formulas, data_only=False)
|
|
1589
|
+
* pptx — Python + python-pptx (only edits text runs, skips charts/images)
|
|
1590
|
+
*
|
|
1591
|
+
* Output: {baseName}_edited.{ext} — never overwrites the original.
|
|
1592
|
+
*/
|
|
1593
|
+
|
|
1594
|
+
"use strict";
|
|
1595
|
+
const fs = require("fs");
|
|
1596
|
+
const path = require("path");
|
|
1597
|
+
const os = require("os");
|
|
1598
|
+
const { execSync, spawnSync } = require("child_process");
|
|
1599
|
+
|
|
1600
|
+
// ── Python detection (mirrors cli-anything-bridge pattern) ───────────────────
|
|
1601
|
+
function detectPython() {
|
|
1602
|
+
const candidates = ["python", "python3", "py"];
|
|
1603
|
+
for (const cmd of candidates) {
|
|
1604
|
+
try {
|
|
1605
|
+
const r = spawnSync(cmd, ["--version"], { encoding: "utf-8", timeout: 5000 });
|
|
1606
|
+
if (r.status === 0) return { found: true, command: cmd };
|
|
1607
|
+
} catch (_e) { /* try next */ }
|
|
1608
|
+
}
|
|
1609
|
+
return { found: false, command: null };
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function checkPythonModule(pyCmd, moduleName) {
|
|
1613
|
+
try {
|
|
1614
|
+
execSync(\`\${pyCmd} -c "import \${moduleName}"\`, { stdio: "ignore", timeout: 10000 });
|
|
1615
|
+
return true;
|
|
1616
|
+
} catch {
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// ── LLM call via CLI sub-process ─────────────────────────────────────────────
|
|
1622
|
+
function callLLM(prompt) {
|
|
1623
|
+
const r = spawnSync(
|
|
1624
|
+
process.execPath,
|
|
1625
|
+
[process.argv[1], "ask", prompt],
|
|
1626
|
+
{ encoding: "utf-8", timeout: 120000, cwd: process.cwd(), env: process.env },
|
|
1627
|
+
);
|
|
1628
|
+
if (r.status === 0 && r.stdout && r.stdout.trim().length > 10) return r.stdout.trim();
|
|
1629
|
+
return null;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// ── Output path helper ────────────────────────────────────────────────────────
|
|
1633
|
+
function buildOutputPath(inputFile, outputDir) {
|
|
1634
|
+
const ext = path.extname(inputFile);
|
|
1635
|
+
const baseName = path.basename(inputFile, ext);
|
|
1636
|
+
const dir = outputDir || path.dirname(inputFile);
|
|
1637
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1638
|
+
return path.join(dir, \`\${baseName}_edited\${ext}\`);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// ── Prompt builders ───────────────────────────────────────────────────────────
|
|
1642
|
+
function buildTextPrompt(content, instruction, action, section) {
|
|
1643
|
+
if (action === "append") {
|
|
1644
|
+
return \`请按照以下指令,在文档末尾追加新内容。只输出追加的新内容,不重复原文档:
|
|
1645
|
+
|
|
1646
|
+
指令:\${instruction}
|
|
1647
|
+
|
|
1648
|
+
原文档内容:
|
|
1649
|
+
\${content}\`;
|
|
1650
|
+
}
|
|
1651
|
+
if (action === "rewrite-section" && section) {
|
|
1652
|
+
return \`请按照以下指令,重写文档中标题为"\${section}"的章节。只输出该章节的完整新内容(包含标题行):
|
|
1653
|
+
|
|
1654
|
+
指令:\${instruction}
|
|
1655
|
+
|
|
1656
|
+
原文档内容:
|
|
1657
|
+
\${content}\`;
|
|
1658
|
+
}
|
|
1659
|
+
// default: edit
|
|
1660
|
+
return \`请按照以下修改指令修改文档,保持原有结构和格式风格,直接输出修改后的完整文档内容:
|
|
1661
|
+
|
|
1662
|
+
修改指令:\${instruction}
|
|
1663
|
+
|
|
1664
|
+
原文档内容:
|
|
1665
|
+
\${content}\`;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// ── md / txt / html handler ───────────────────────────────────────────────────
|
|
1669
|
+
function editText(inputFile, instruction, action, section, outputDir) {
|
|
1670
|
+
const content = fs.readFileSync(inputFile, "utf-8");
|
|
1671
|
+
const prompt = buildTextPrompt(content, instruction, action, section);
|
|
1672
|
+
const llmResult = callLLM(prompt);
|
|
1673
|
+
|
|
1674
|
+
let newContent;
|
|
1675
|
+
if (llmResult) {
|
|
1676
|
+
if (action === "append") {
|
|
1677
|
+
newContent = content + "\\n\\n" + llmResult;
|
|
1678
|
+
} else if (action === "rewrite-section" && section) {
|
|
1679
|
+
const lines = content.split("\\n");
|
|
1680
|
+
const sectionStart = lines.findIndex((l) => /^#{1,6}\s/.test(l) && l.includes(section));
|
|
1681
|
+
if (sectionStart >= 0) {
|
|
1682
|
+
const nextSection = lines.findIndex((l, i) => i > sectionStart && /^#{1,6}\s/.test(l));
|
|
1683
|
+
const before = lines.slice(0, sectionStart).join("\\n");
|
|
1684
|
+
const after = nextSection >= 0 ? lines.slice(nextSection).join("\\n") : "";
|
|
1685
|
+
newContent = (before ? before + "\\n" : "") + llmResult + (after ? "\\n" + after : "");
|
|
1686
|
+
} else {
|
|
1687
|
+
newContent = content + "\\n\\n" + llmResult;
|
|
1688
|
+
}
|
|
1689
|
+
} else {
|
|
1690
|
+
newContent = llmResult;
|
|
1691
|
+
}
|
|
1692
|
+
} else {
|
|
1693
|
+
return { success: false, error: "LLM 调用失败,请检查 chainlesschain ask 是否可用", hint: "运行 chainlesschain llm test 检查 LLM 连接" };
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const outputFile = buildOutputPath(inputFile, outputDir);
|
|
1697
|
+
fs.writeFileSync(outputFile, newContent, "utf-8");
|
|
1698
|
+
return {
|
|
1699
|
+
success: true,
|
|
1700
|
+
input: inputFile,
|
|
1701
|
+
output: outputFile,
|
|
1702
|
+
action,
|
|
1703
|
+
message: \`Document edited and saved to: \${outputFile}\`,
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// ── docx handler ──────────────────────────────────────────────────────────────
|
|
1708
|
+
function editDocx(inputFile, instruction, action, section, outputDir) {
|
|
1709
|
+
// Try pandoc: docx → markdown → LLM → markdown → docx
|
|
1710
|
+
try {
|
|
1711
|
+
const r = spawnSync("pandoc", ["--version"], { encoding: "utf-8", timeout: 5000 });
|
|
1712
|
+
if (r.status === 0) {
|
|
1713
|
+
const tmpMd = path.join(os.tmpdir(), \`doc_edit_\${Date.now()}.md\`);
|
|
1714
|
+
spawnSync("pandoc", [inputFile, "-o", tmpMd], { encoding: "utf-8", timeout: 60000 });
|
|
1715
|
+
if (fs.existsSync(tmpMd)) {
|
|
1716
|
+
const content = fs.readFileSync(tmpMd, "utf-8");
|
|
1717
|
+
const prompt = buildTextPrompt(content, instruction, action, section);
|
|
1718
|
+
const llmResult = callLLM(prompt);
|
|
1719
|
+
if (llmResult) {
|
|
1720
|
+
let newContent = action === "append" ? content + "\\n\\n" + llmResult : llmResult;
|
|
1721
|
+
fs.writeFileSync(tmpMd, newContent, "utf-8");
|
|
1722
|
+
const outputFile = buildOutputPath(inputFile, outputDir);
|
|
1723
|
+
spawnSync("pandoc", [tmpMd, "-o", outputFile], { encoding: "utf-8", timeout: 60000 });
|
|
1724
|
+
try { fs.unlinkSync(tmpMd); } catch (_e) { /* ignore */ }
|
|
1725
|
+
if (fs.existsSync(outputFile)) {
|
|
1726
|
+
return { success: true, input: inputFile, output: outputFile, action, message: \`DOCX edited via pandoc: \${outputFile}\` };
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
try { fs.unlinkSync(tmpMd); } catch (_e) { /* ignore */ }
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
} catch (_e) { /* pandoc not available, try soffice */ }
|
|
1733
|
+
|
|
1734
|
+
// Try soffice: docx → html → LLM → html → docx
|
|
1735
|
+
const sofficeCandidates = ["soffice", "libreoffice"];
|
|
1736
|
+
if (process.platform === "win32") {
|
|
1737
|
+
const winPaths = [
|
|
1738
|
+
"C:\\\\Program Files\\\\LibreOffice\\\\program\\\\soffice.exe",
|
|
1739
|
+
"C:\\\\Program Files (x86)\\\\LibreOffice\\\\program\\\\soffice.exe",
|
|
1740
|
+
];
|
|
1741
|
+
for (const p of winPaths) {
|
|
1742
|
+
if (fs.existsSync(p)) sofficeCandidates.unshift(p);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
for (const soffice of sofficeCandidates) {
|
|
1746
|
+
try {
|
|
1747
|
+
const rv = spawnSync(soffice, ["--version"], { encoding: "utf-8", timeout: 5000, shell: process.platform === "win32" });
|
|
1748
|
+
if (rv.status === 0) {
|
|
1749
|
+
const tmpDir2 = os.tmpdir();
|
|
1750
|
+
spawnSync(soffice, ["--headless", "--convert-to", "html", inputFile, "--outdir", tmpDir2], {
|
|
1751
|
+
encoding: "utf-8", timeout: 60000, shell: process.platform === "win32",
|
|
1752
|
+
});
|
|
1753
|
+
const baseName = path.basename(inputFile, ".docx");
|
|
1754
|
+
const htmlFile = path.join(tmpDir2, \`\${baseName}.html\`);
|
|
1755
|
+
if (fs.existsSync(htmlFile)) {
|
|
1756
|
+
const content = fs.readFileSync(htmlFile, "utf-8");
|
|
1757
|
+
const prompt = buildTextPrompt(content, instruction, action, section);
|
|
1758
|
+
const llmResult = callLLM(prompt);
|
|
1759
|
+
if (llmResult) {
|
|
1760
|
+
fs.writeFileSync(htmlFile, llmResult, "utf-8");
|
|
1761
|
+
const outputFile = buildOutputPath(inputFile, outputDir);
|
|
1762
|
+
spawnSync(soffice, ["--headless", "--convert-to", "docx", htmlFile, "--outdir", path.dirname(outputFile)], {
|
|
1763
|
+
encoding: "utf-8", timeout: 60000, shell: process.platform === "win32",
|
|
1764
|
+
});
|
|
1765
|
+
try { fs.unlinkSync(htmlFile); } catch (_e) { /* ignore */ }
|
|
1766
|
+
if (fs.existsSync(outputFile)) {
|
|
1767
|
+
return { success: true, input: inputFile, output: outputFile, action, message: \`DOCX edited via LibreOffice: \${outputFile}\` };
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
try { fs.unlinkSync(htmlFile); } catch (_e) { /* ignore */ }
|
|
1771
|
+
}
|
|
1772
|
+
break;
|
|
1773
|
+
}
|
|
1774
|
+
} catch (_e) { /* try next */ }
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
return {
|
|
1778
|
+
success: false,
|
|
1779
|
+
error: "需要安装 pandoc 或 LibreOffice 才能修改 DOCX 文件",
|
|
1780
|
+
hint: "安装 pandoc: winget install pandoc 或 安装 LibreOffice: winget install LibreOffice.LibreOffice",
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// ── xlsx handler (Python + openpyxl) ─────────────────────────────────────────
|
|
1785
|
+
function editXlsx(inputFile, instruction, action, outputDir) {
|
|
1786
|
+
const py = detectPython();
|
|
1787
|
+
if (!py.found) {
|
|
1788
|
+
return { success: false, error: "未找到 Python,请安装 Python 3.x", hint: "https://python.org" };
|
|
1789
|
+
}
|
|
1790
|
+
if (!checkPythonModule(py.command, "openpyxl")) {
|
|
1791
|
+
return {
|
|
1792
|
+
success: false,
|
|
1793
|
+
error: "需要安装 openpyxl 才能修改 XLSX 文件",
|
|
1794
|
+
hint: \`运行: \${py.command} -m pip install openpyxl\`,
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
const tmpExtract = path.join(os.tmpdir(), \`xlsx_extract_\${Date.now()}.py\`);
|
|
1799
|
+
const tmpApply = path.join(os.tmpdir(), \`xlsx_apply_\${Date.now()}.py\`);
|
|
1800
|
+
const tmpJson = path.join(os.tmpdir(), \`xlsx_cells_\${Date.now()}.json\`);
|
|
1801
|
+
|
|
1802
|
+
try {
|
|
1803
|
+
// Step 1: extract text cells (non-formula string cells)
|
|
1804
|
+
const extractScript = \`
|
|
1805
|
+
import json, openpyxl
|
|
1806
|
+
wb = openpyxl.load_workbook(r"""\${inputFile}""", data_only=False)
|
|
1807
|
+
cells = []
|
|
1808
|
+
for ws in wb.worksheets:
|
|
1809
|
+
for row in ws.iter_rows():
|
|
1810
|
+
for cell in row:
|
|
1811
|
+
if cell.data_type == 's' and cell.value is not None:
|
|
1812
|
+
cells.append({"sheet": ws.title, "row": cell.row, "col": cell.column, "value": cell.value})
|
|
1813
|
+
with open(r"""\${tmpJson}""", "w", encoding="utf-8") as f:
|
|
1814
|
+
json.dump(cells, f, ensure_ascii=False)
|
|
1815
|
+
\`;
|
|
1816
|
+
fs.writeFileSync(tmpExtract, extractScript, "utf-8");
|
|
1817
|
+
const er = spawnSync(py.command, [tmpExtract], { encoding: "utf-8", timeout: 30000 });
|
|
1818
|
+
if (er.status !== 0) throw new Error(er.stderr || "extract failed");
|
|
1819
|
+
|
|
1820
|
+
const cells = JSON.parse(fs.readFileSync(tmpJson, "utf-8"));
|
|
1821
|
+
if (cells.length === 0) {
|
|
1822
|
+
return { success: false, error: "未找到可修改的文本单元格(所有单元格均为公式或数值)" };
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// Step 2: LLM editing
|
|
1826
|
+
const textList = cells.map((c, i) => \`[\${i}] \${c.sheet}!R\${c.row}C\${c.col}: \${c.value}\`).join("\\n");
|
|
1827
|
+
const prompt = \`请按照以下修改指令修改 Excel 文本单元格内容。只修改需要修改的单元格,不需要修改的保持原值。
|
|
1828
|
+
以 JSON 数组格式返回所有单元格(包括未修改的),每个元素保持原有的 sheet/row/col 字段,只更新 value 字段。
|
|
1829
|
+
|
|
1830
|
+
修改指令:\${instruction}
|
|
1831
|
+
|
|
1832
|
+
当前文本单元格:
|
|
1833
|
+
\${textList}
|
|
1834
|
+
|
|
1835
|
+
直接返回 JSON 数组,不要其他说明。\`;
|
|
1836
|
+
const llmResult = callLLM(prompt);
|
|
1837
|
+
if (!llmResult) {
|
|
1838
|
+
return { success: false, error: "LLM 调用失败", hint: "运行 chainlesschain llm test 检查连接" };
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// Parse LLM result
|
|
1842
|
+
let updatedCells;
|
|
1843
|
+
try {
|
|
1844
|
+
const jsonMatch = llmResult.match(/\\[\\s*\\{[\\s\\S]*\\}\\s*\\]/);
|
|
1845
|
+
updatedCells = JSON.parse(jsonMatch ? jsonMatch[0] : llmResult);
|
|
1846
|
+
} catch (_e) {
|
|
1847
|
+
return { success: false, error: "LLM 返回格式不正确,无法解析 JSON", hint: "请重试或简化修改指令" };
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// Step 3: apply changes
|
|
1851
|
+
const outputFile = buildOutputPath(inputFile, outputDir);
|
|
1852
|
+
const applyScript = \`
|
|
1853
|
+
import json, openpyxl, shutil
|
|
1854
|
+
shutil.copy2(r"""\${inputFile}""", r"""\${outputFile}""")
|
|
1855
|
+
wb = openpyxl.load_workbook(r"""\${outputFile}""", data_only=False)
|
|
1856
|
+
updated = json.loads('''
|
|
1857
|
+
\${JSON.stringify(updatedCells).replace(/'/g, "\\\\'")}
|
|
1858
|
+
''')
|
|
1859
|
+
for item in updated:
|
|
1860
|
+
ws = wb[item["sheet"]]
|
|
1861
|
+
cell = ws.cell(row=item["row"], column=item["col"])
|
|
1862
|
+
if cell.data_type == 's':
|
|
1863
|
+
cell.value = item["value"]
|
|
1864
|
+
wb.save(r"""\${outputFile}""")
|
|
1865
|
+
\`;
|
|
1866
|
+
fs.writeFileSync(tmpApply, applyScript, "utf-8");
|
|
1867
|
+
const ar = spawnSync(py.command, [tmpApply], { encoding: "utf-8", timeout: 30000 });
|
|
1868
|
+
if (ar.status !== 0) throw new Error(ar.stderr || "apply failed");
|
|
1869
|
+
|
|
1870
|
+
return {
|
|
1871
|
+
success: true,
|
|
1872
|
+
input: inputFile,
|
|
1873
|
+
output: outputFile,
|
|
1874
|
+
action: action || "edit",
|
|
1875
|
+
message: \`XLSX edited (formulas preserved): \${outputFile}\`,
|
|
1876
|
+
cellsModified: updatedCells.length,
|
|
1877
|
+
};
|
|
1878
|
+
} catch (err) {
|
|
1879
|
+
return { success: false, error: \`XLSX 修改失败: \${err.message}\` };
|
|
1880
|
+
} finally {
|
|
1881
|
+
try { fs.unlinkSync(tmpExtract); } catch (_e) { /* ignore */ }
|
|
1882
|
+
try { fs.unlinkSync(tmpApply); } catch (_e) { /* ignore */ }
|
|
1883
|
+
try { fs.unlinkSync(tmpJson); } catch (_e) { /* ignore */ }
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// ── pptx handler (Python + python-pptx) ──────────────────────────────────────
|
|
1888
|
+
function editPptx(inputFile, instruction, action, outputDir) {
|
|
1889
|
+
const py = detectPython();
|
|
1890
|
+
if (!py.found) {
|
|
1891
|
+
return { success: false, error: "未找到 Python,请安装 Python 3.x", hint: "https://python.org" };
|
|
1892
|
+
}
|
|
1893
|
+
if (!checkPythonModule(py.command, "pptx")) {
|
|
1894
|
+
return {
|
|
1895
|
+
success: false,
|
|
1896
|
+
error: "需要安装 python-pptx 才能修改 PPTX 文件",
|
|
1897
|
+
hint: \`运行: \${py.command} -m pip install python-pptx\`,
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const tmpExtract = path.join(os.tmpdir(), \`pptx_extract_\${Date.now()}.py\`);
|
|
1902
|
+
const tmpApply = path.join(os.tmpdir(), \`pptx_apply_\${Date.now()}.py\`);
|
|
1903
|
+
const tmpJson = path.join(os.tmpdir(), \`pptx_runs_\${Date.now()}.json\`);
|
|
1904
|
+
|
|
1905
|
+
try {
|
|
1906
|
+
// Step 1: extract text runs (skip chart shapes)
|
|
1907
|
+
const extractScript = \`
|
|
1908
|
+
import json
|
|
1909
|
+
from pptx import Presentation
|
|
1910
|
+
prs = Presentation(r"""\${inputFile}""")
|
|
1911
|
+
runs = []
|
|
1912
|
+
for si, slide in enumerate(prs.slides):
|
|
1913
|
+
for shi, shape in enumerate(slide.shapes):
|
|
1914
|
+
if not shape.has_text_frame:
|
|
1915
|
+
continue
|
|
1916
|
+
for pi, para in enumerate(shape.text_frame.paragraphs):
|
|
1917
|
+
for ri, run in enumerate(para.runs):
|
|
1918
|
+
if run.text.strip():
|
|
1919
|
+
runs.append({"slide": si, "shape": shi, "para": pi, "run": ri, "text": run.text})
|
|
1920
|
+
with open(r"""\${tmpJson}""", "w", encoding="utf-8") as f:
|
|
1921
|
+
json.dump(runs, f, ensure_ascii=False)
|
|
1922
|
+
\`;
|
|
1923
|
+
fs.writeFileSync(tmpExtract, extractScript, "utf-8");
|
|
1924
|
+
const er = spawnSync(py.command, [tmpExtract], { encoding: "utf-8", timeout: 30000 });
|
|
1925
|
+
if (er.status !== 0) throw new Error(er.stderr || "extract failed");
|
|
1926
|
+
|
|
1927
|
+
const runs = JSON.parse(fs.readFileSync(tmpJson, "utf-8"));
|
|
1928
|
+
if (runs.length === 0) {
|
|
1929
|
+
return { success: false, error: "未找到可修改的文本内容(PPT 可能只包含图表或图片)" };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// Step 2: LLM editing
|
|
1933
|
+
const textList = runs.map((r, i) => \`[\${i}] Slide\${r.slide + 1}/Shape\${r.shape}/Para\${r.para}/Run\${r.run}: \${r.text}\`).join("\\n");
|
|
1934
|
+
const prompt = \`请按照以下修改指令修改 PowerPoint 文本内容。只修改需要修改的文本 run,不需要修改的保持原文。
|
|
1935
|
+
以 JSON 数组格式返回所有 run(包括未修改的),保持原有的 slide/shape/para/run 字段,只更新 text 字段。
|
|
1936
|
+
|
|
1937
|
+
修改指令:\${instruction}
|
|
1938
|
+
|
|
1939
|
+
当前文本 run:
|
|
1940
|
+
\${textList}
|
|
1941
|
+
|
|
1942
|
+
直接返回 JSON 数组,不要其他说明。\`;
|
|
1943
|
+
const llmResult = callLLM(prompt);
|
|
1944
|
+
if (!llmResult) {
|
|
1945
|
+
return { success: false, error: "LLM 调用失败", hint: "运行 chainlesschain llm test 检查连接" };
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
let updatedRuns;
|
|
1949
|
+
try {
|
|
1950
|
+
const jsonMatch = llmResult.match(/\\[\\s*\\{[\\s\\S]*\\}\\s*\\]/);
|
|
1951
|
+
updatedRuns = JSON.parse(jsonMatch ? jsonMatch[0] : llmResult);
|
|
1952
|
+
} catch (_e) {
|
|
1953
|
+
return { success: false, error: "LLM 返回格式不正确,无法解析 JSON", hint: "请重试或简化修改指令" };
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// Step 3: apply changes (only .text, preserve all font/style)
|
|
1957
|
+
const outputFile = buildOutputPath(inputFile, outputDir);
|
|
1958
|
+
const applyScript = \`
|
|
1959
|
+
import json, shutil
|
|
1960
|
+
from pptx import Presentation
|
|
1961
|
+
shutil.copy2(r"""\${inputFile}""", r"""\${outputFile}""")
|
|
1962
|
+
prs = Presentation(r"""\${outputFile}""")
|
|
1963
|
+
updated = json.loads('''
|
|
1964
|
+
\${JSON.stringify(updatedRuns).replace(/'/g, "\\\\'")}
|
|
1965
|
+
''')
|
|
1966
|
+
for item in updated:
|
|
1967
|
+
slide = prs.slides[item["slide"]]
|
|
1968
|
+
shape = slide.shapes[item["shape"]]
|
|
1969
|
+
if shape.has_text_frame:
|
|
1970
|
+
run = shape.text_frame.paragraphs[item["para"]].runs[item["run"]]
|
|
1971
|
+
run.text = item["text"]
|
|
1972
|
+
prs.save(r"""\${outputFile}""")
|
|
1973
|
+
\`;
|
|
1974
|
+
fs.writeFileSync(tmpApply, applyScript, "utf-8");
|
|
1975
|
+
const ar = spawnSync(py.command, [tmpApply], { encoding: "utf-8", timeout: 30000 });
|
|
1976
|
+
if (ar.status !== 0) throw new Error(ar.stderr || "apply failed");
|
|
1977
|
+
|
|
1978
|
+
return {
|
|
1979
|
+
success: true,
|
|
1980
|
+
input: inputFile,
|
|
1981
|
+
output: outputFile,
|
|
1982
|
+
action: action || "edit",
|
|
1983
|
+
message: \`PPTX edited (charts/images preserved): \${outputFile}\`,
|
|
1984
|
+
runsModified: updatedRuns.length,
|
|
1985
|
+
};
|
|
1986
|
+
} catch (err) {
|
|
1987
|
+
return { success: false, error: \`PPTX 修改失败: \${err.message}\` };
|
|
1988
|
+
} finally {
|
|
1989
|
+
try { fs.unlinkSync(tmpExtract); } catch (_e) { /* ignore */ }
|
|
1990
|
+
try { fs.unlinkSync(tmpApply); } catch (_e) { /* ignore */ }
|
|
1991
|
+
try { fs.unlinkSync(tmpJson); } catch (_e) { /* ignore */ }
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// ── Main export ───────────────────────────────────────────────────────────────
|
|
1996
|
+
async function docEdit(params) {
|
|
1997
|
+
const { input_file, instruction, action = "edit", section, output_dir } = params || {};
|
|
1998
|
+
|
|
1999
|
+
if (!input_file) {
|
|
2000
|
+
return { success: false, error: "Missing required parameter: input_file" };
|
|
2001
|
+
}
|
|
2002
|
+
if (!instruction) {
|
|
2003
|
+
return { success: false, error: "Missing required parameter: instruction" };
|
|
2004
|
+
}
|
|
2005
|
+
if (!fs.existsSync(input_file)) {
|
|
2006
|
+
return { success: false, error: \`Input file not found: \${input_file}\` };
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
const validActions = ["edit", "append", "rewrite-section"];
|
|
2010
|
+
if (!validActions.includes(action)) {
|
|
2011
|
+
return { success: false, error: \`Unsupported action: \${action}\`, hint: \`Supported actions: \${validActions.join(", ")}\` };
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
const ext = path.extname(input_file).toLowerCase();
|
|
2015
|
+
|
|
2016
|
+
if ([".md", ".txt", ".html", ".htm"].includes(ext)) {
|
|
2017
|
+
return editText(input_file, instruction, action, section, output_dir);
|
|
2018
|
+
}
|
|
2019
|
+
if (ext === ".docx") {
|
|
2020
|
+
return editDocx(input_file, instruction, action, section, output_dir);
|
|
2021
|
+
}
|
|
2022
|
+
if (ext === ".xlsx") {
|
|
2023
|
+
return editXlsx(input_file, instruction, action, output_dir);
|
|
2024
|
+
}
|
|
2025
|
+
if (ext === ".pptx") {
|
|
2026
|
+
return editPptx(input_file, instruction, action, output_dir);
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
return {
|
|
2030
|
+
success: false,
|
|
2031
|
+
error: \`不支持的格式: \${ext}\`,
|
|
2032
|
+
hint: "支持的格式: md, txt, html, docx, xlsx, pptx",
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
docEdit.execute = async (task, _ctx, _skill) => {
|
|
2037
|
+
const input = typeof task === "string" ? task : (task.input || task.params?.input || "");
|
|
2038
|
+
let p = {};
|
|
2039
|
+
try { p = input.trim().startsWith("{") ? JSON.parse(input) : { input_file: input }; }
|
|
2040
|
+
catch { p = { input_file: input }; }
|
|
2041
|
+
return docEdit(p);
|
|
2042
|
+
};
|
|
2043
|
+
module.exports = docEdit;
|
|
2044
|
+
`,
|
|
2045
|
+
},
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
const DOC_TEMPLATES_README = `# Document Templates
|
|
2049
|
+
|
|
2050
|
+
此目录存放 AI 文档生成的模板文件。
|
|
2051
|
+
|
|
2052
|
+
## 使用 AI 生成文档
|
|
2053
|
+
|
|
2054
|
+
\`\`\`bash
|
|
2055
|
+
# 生成分析报告(Markdown)
|
|
2056
|
+
chainlesschain skill run doc-generate "2026年技术趋势分析"
|
|
2057
|
+
|
|
2058
|
+
# 生成项目方案(DOCX,需要 pandoc 或 LibreOffice)
|
|
2059
|
+
chainlesschain skill run doc-generate "电商平台重构方案" --args '{"style":"proposal","format":"docx"}'
|
|
2060
|
+
|
|
2061
|
+
# 生成 PDF(需要 LibreOffice)
|
|
2062
|
+
chainlesschain skill run doc-generate "季度运营报告" --args '{"format":"pdf"}'
|
|
2063
|
+
|
|
2064
|
+
# 提供大纲精确控制结构
|
|
2065
|
+
chainlesschain skill run doc-generate "产品路线图" --args '{"outline":"1.当前状态 2.Q1目标 3.Q2目标 4.风险与缓解 5.资源需求"}'
|
|
2066
|
+
\`\`\`
|
|
2067
|
+
|
|
2068
|
+
## LibreOffice 格式转换
|
|
2069
|
+
|
|
2070
|
+
\`\`\`bash
|
|
2071
|
+
# 将 Word 文档转换为 PDF
|
|
2072
|
+
chainlesschain skill run libre-convert "report.docx"
|
|
2073
|
+
|
|
2074
|
+
# 将 Markdown 转换为 DOCX
|
|
2075
|
+
chainlesschain skill run libre-convert "readme.md" --args '{"format":"docx"}'
|
|
2076
|
+
\`\`\`
|
|
2077
|
+
|
|
2078
|
+
## cli-anything 集成
|
|
2079
|
+
|
|
2080
|
+
LibreOffice 具有完整的 CLI 接口(soffice --headless),可以通过 cli-anything 注册以获得完整功能访问:
|
|
2081
|
+
|
|
2082
|
+
\`\`\`bash
|
|
2083
|
+
# 注册 LibreOffice CLI(适用于高级用途:宏、模板、批量操作)
|
|
2084
|
+
chainlesschain cli-anything scan # 检测 PATH 中的工具
|
|
2085
|
+
chainlesschain cli-anything register soffice
|
|
2086
|
+
chainlesschain cli-anything list # 查看已注册工具
|
|
2087
|
+
|
|
2088
|
+
# 注册 pandoc(通用文档转换)
|
|
2089
|
+
chainlesschain cli-anything register pandoc
|
|
2090
|
+
\`\`\`
|
|
2091
|
+
|
|
2092
|
+
适合通过 cli-anything 注册的文档工具(有 CLI 接口):
|
|
2093
|
+
- LibreOffice / soffice(全格式转换 + 宏执行)
|
|
2094
|
+
- pandoc(多格式转换,尤其 md→docx)
|
|
2095
|
+
- wkhtmltopdf(HTML→PDF 高保真)
|
|
2096
|
+
|
|
2097
|
+
> **设计边界**:LibreOffice 的日常转换和 AI 文档生成使用 workspace 技能(doc-generate / libre-convert);
|
|
2098
|
+
> 需要直接控制 LibreOffice 高级功能(宏、模板样式、批量脚本)时,通过 cli-anything 注册 soffice 更灵活。
|
|
2099
|
+
|
|
2100
|
+
## 自定义模板
|
|
2101
|
+
|
|
2102
|
+
在此目录中创建 \`.md\` 模板文件,并在 \`doc-generate\` 技能中通过 outline 参数引用:
|
|
2103
|
+
|
|
2104
|
+
\`\`\`bash
|
|
2105
|
+
# 创建自定义模板结构
|
|
2106
|
+
cat templates/weekly-report-template.md
|
|
2107
|
+
# 1.本周完成事项 2.下周计划 3.风险与阻塞 4.需要支持
|
|
2108
|
+
|
|
2109
|
+
chainlesschain skill run doc-generate "2026-W12 周报" \\
|
|
2110
|
+
--args '{"outline":"1.本周完成事项 2.下周计划 3.风险与阻塞 4.需要支持","style":"report"}'
|
|
2111
|
+
\`\`\`
|
|
2112
|
+
`;
|
|
2113
|
+
|
|
14
2114
|
const TEMPLATES = {
|
|
15
2115
|
"code-project": {
|
|
16
2116
|
description:
|
|
@@ -150,6 +2250,135 @@ const TEMPLATES = {
|
|
|
150
2250
|
toolsDisabled: [],
|
|
151
2251
|
},
|
|
152
2252
|
},
|
|
2253
|
+
"ai-doc-creator": {
|
|
2254
|
+
description:
|
|
2255
|
+
"AI 文档创作项目,集成 LibreOffice 格式转换与 AI 驱动的文档生成(报告/方案/说明书)",
|
|
2256
|
+
rules: `# AI 文档创作项目规则
|
|
2257
|
+
|
|
2258
|
+
## 文档生成
|
|
2259
|
+
|
|
2260
|
+
- 使用 \`chainlesschain skill run doc-generate "主题"\` 让 AI 生成结构化文档
|
|
2261
|
+
- 支持输出格式:md(默认)/ html / docx / pdf
|
|
2262
|
+
- 文档模板和大纲保存至 \`templates/\` 目录,通过 \`--args '{"outline":"..."}'\` 引用
|
|
2263
|
+
|
|
2264
|
+
## LibreOffice 集成
|
|
2265
|
+
|
|
2266
|
+
- 格式转换使用 \`chainlesschain skill run libre-convert <file>\`
|
|
2267
|
+
- LibreOffice 默认 URL:本地安装(soffice --headless)
|
|
2268
|
+
- DOCX 转换优先使用 pandoc(安装:\`winget install pandoc\`)
|
|
2269
|
+
- PDF 导出优先使用 LibreOffice(安装:\`winget install LibreOffice.LibreOffice\`)
|
|
2270
|
+
|
|
2271
|
+
## cli-anything 集成说明
|
|
2272
|
+
|
|
2273
|
+
LibreOffice 具有完整的 CLI 接口(\`soffice --headless\`),**适合通过 cli-anything 注册**以获得高级功能访问:
|
|
2274
|
+
|
|
2275
|
+
\`\`\`bash
|
|
2276
|
+
# 日常转换 → 使用内置技能
|
|
2277
|
+
chainlesschain skill run libre-convert "report.docx"
|
|
2278
|
+
|
|
2279
|
+
# 高级操作(宏/模板/批量脚本)→ 通过 cli-anything 注册
|
|
2280
|
+
chainlesschain cli-anything register soffice # 注册完整 LibreOffice CLI
|
|
2281
|
+
chainlesschain cli-anything register pandoc # 注册 pandoc
|
|
2282
|
+
\`\`\`
|
|
2283
|
+
|
|
2284
|
+
适合 cli-anything 注册的文档工具:
|
|
2285
|
+
- \`soffice\` / \`libreoffice\`(LibreOffice,全功能 CLI)
|
|
2286
|
+
- \`pandoc\`(通用文档转换)
|
|
2287
|
+
- \`wkhtmltopdf\`(HTML→PDF 高保真)
|
|
2288
|
+
|
|
2289
|
+
## AI 文档修改
|
|
2290
|
+
|
|
2291
|
+
- 使用 \`chainlesschain skill run doc-edit\` 修改现有文档内容
|
|
2292
|
+
- xlsx/pptx 修改需要 Python + openpyxl/python-pptx(公式/图表/样式完全保留)
|
|
2293
|
+
- 输出命名规则:\`{原文件名}_edited.{扩展名}\`,永不覆盖原文件
|
|
2294
|
+
|
|
2295
|
+
## AI 助手行为准则
|
|
2296
|
+
|
|
2297
|
+
- 优先使用 doc-generate 技能生成内容,libre-convert 技能转换格式,doc-edit 技能修改内容
|
|
2298
|
+
- 批量文档任务推荐使用 agent 模式:\`chainlesschain agent\`
|
|
2299
|
+
- 大型文档(>10000字)建议分章节生成,再合并
|
|
2300
|
+
- 修改 xlsx/pptx 前确认用户已安装 openpyxl/python-pptx
|
|
2301
|
+
- 涉及敏感数据的文档,提示用户注意本地 LLM 模式
|
|
2302
|
+
`,
|
|
2303
|
+
skills: ["doc-generate", "libre-convert", "doc-edit", "summarize"],
|
|
2304
|
+
persona: {
|
|
2305
|
+
name: "AI文档助手",
|
|
2306
|
+
role: "你是一个专业的AI文档创作助手,擅长生成各类结构化文档(报告、方案、说明书、README等),熟悉 LibreOffice 文档格式转换和 pandoc 文档处理。你能根据用户需求自动规划文档结构,生成专业、清晰的内容。",
|
|
2307
|
+
behaviors: [
|
|
2308
|
+
"根据用户描述自动选择合适的文档风格(报告/方案/说明书/README)",
|
|
2309
|
+
"主动询问文档目标读者和使用场景以优化内容",
|
|
2310
|
+
"批量任务前确认 LibreOffice 已安装或告知安装方式",
|
|
2311
|
+
"对长文档建议分章节生成以确保质量",
|
|
2312
|
+
],
|
|
2313
|
+
toolsPriority: ["run_shell", "write_file", "read_file"],
|
|
2314
|
+
toolsDisabled: [],
|
|
2315
|
+
},
|
|
2316
|
+
generateSkills: ["doc-generate", "libre-convert", "doc-edit"],
|
|
2317
|
+
generateDir: "templates",
|
|
2318
|
+
generateDirReadme: "DOC_TEMPLATES_README",
|
|
2319
|
+
},
|
|
2320
|
+
"ai-media-creator": {
|
|
2321
|
+
description:
|
|
2322
|
+
"AI音视频创作项目,集成ComfyUI图像/视频生成与AI音频合成(TTS/音乐)",
|
|
2323
|
+
rules: `# AI 音视频创作项目规则
|
|
2324
|
+
|
|
2325
|
+
## ComfyUI 配置
|
|
2326
|
+
|
|
2327
|
+
- ComfyUI 服务地址:\`COMFYUI_URL\` 环境变量(默认 http://localhost:8188)
|
|
2328
|
+
- 工作流文件保存至项目 \`workflows/\` 目录(API Format JSON 格式)
|
|
2329
|
+
- 使用 \`chainlesschain skill run comfyui-image\` 进行文生图
|
|
2330
|
+
- 使用 \`chainlesschain skill run comfyui-video\` 进行 AnimateDiff 视频生成
|
|
2331
|
+
|
|
2332
|
+
## 音频生成
|
|
2333
|
+
|
|
2334
|
+
- TTS 优先使用 \`edge-tts\`(免费):\`pip install edge-tts\`
|
|
2335
|
+
- 高质量 TTS 可配置 \`ELEVENLABS_API_KEY\` 或 \`OPENAI_API_KEY\`
|
|
2336
|
+
- 使用 \`chainlesschain skill run audio-gen\` 合成语音
|
|
2337
|
+
|
|
2338
|
+
## 工作流规范
|
|
2339
|
+
|
|
2340
|
+
- 所有 ComfyUI 工作流以 API Format 保存在 \`workflows/\` 目录
|
|
2341
|
+
- 使用有意义的文件名(如 \`txt2img-sd15.json\`、\`animatediff-v3.json\`)
|
|
2342
|
+
- 批量创作任务推荐使用 agent 模式:\`chainlesschain agent\`
|
|
2343
|
+
|
|
2344
|
+
## cli-anything 集成说明
|
|
2345
|
+
|
|
2346
|
+
当用户安装了有 CLI 接口的 AI 工具时,可通过以下命令注册到 ChainlessChain:
|
|
2347
|
+
|
|
2348
|
+
\`\`\`bash
|
|
2349
|
+
chainlesschain cli-anything register <tool-name>
|
|
2350
|
+
\`\`\`
|
|
2351
|
+
|
|
2352
|
+
适合注册的工具(有 CLI 接口):
|
|
2353
|
+
- FFmpeg(\`ffmpeg\`)
|
|
2354
|
+
- yt-dlp(\`yt-dlp\`)
|
|
2355
|
+
- 第三方 ComfyUI CLI 包装脚本
|
|
2356
|
+
- audiogen-cli、bark-cli 等音频生成 CLI
|
|
2357
|
+
|
|
2358
|
+
注意:ComfyUI 本身以 REST API 为主,不适合通过 cli-anything 注册,
|
|
2359
|
+
请直接使用 \`comfyui-image\` 和 \`comfyui-video\` 技能。
|
|
2360
|
+
|
|
2361
|
+
## AI 助手行为准则
|
|
2362
|
+
|
|
2363
|
+
- 优先使用项目 workflows/ 中已保存的工作流
|
|
2364
|
+
- 批量任务使用 agent 模式以获得最佳控制
|
|
2365
|
+
- 生成大文件时提示用户确认存储空间
|
|
2366
|
+
`,
|
|
2367
|
+
skills: ["comfyui-image", "comfyui-video", "audio-gen", "summarize"],
|
|
2368
|
+
persona: {
|
|
2369
|
+
name: "AI创作助手",
|
|
2370
|
+
role: "你是一个专业的AI音视频创作助手,帮助用户利用 ComfyUI、AnimateDiff 和 AI 音频合成工具创作高质量的图像、视频和音频内容。你熟悉 Stable Diffusion 生图技术、提示词工程和 AnimateDiff 动画生成。",
|
|
2371
|
+
behaviors: [
|
|
2372
|
+
"根据用户创作需求推荐合适的工作流和参数",
|
|
2373
|
+
"提供专业的 Stable Diffusion 提示词建议",
|
|
2374
|
+
"在批量任务前确认存储空间和 ComfyUI 连接状态",
|
|
2375
|
+
"推荐免费开源工具(edge-tts、piper-tts)优先于付费 API",
|
|
2376
|
+
],
|
|
2377
|
+
toolsPriority: ["run_shell", "write_file", "read_file"],
|
|
2378
|
+
toolsDisabled: [],
|
|
2379
|
+
},
|
|
2380
|
+
generateSkills: ["comfyui-image", "comfyui-video", "audio-gen"],
|
|
2381
|
+
},
|
|
153
2382
|
empty: {
|
|
154
2383
|
description: "Bare project with minimal configuration",
|
|
155
2384
|
rules: `# Project Rules
|
|
@@ -169,7 +2398,7 @@ export function registerInitCommand(program) {
|
|
|
169
2398
|
)
|
|
170
2399
|
.option(
|
|
171
2400
|
"-t, --template <name>",
|
|
172
|
-
"Project template (code-project, data-science, devops, medical-triage, agriculture-expert, general-assistant, empty)",
|
|
2401
|
+
"Project template (code-project, data-science, devops, medical-triage, agriculture-expert, general-assistant, ai-media-creator, ai-doc-creator, empty)",
|
|
173
2402
|
"empty",
|
|
174
2403
|
)
|
|
175
2404
|
.option("-y, --yes", "Skip prompts, use defaults")
|
|
@@ -276,6 +2505,41 @@ ${tmpl.persona.behaviors?.map((b) => `- ${b}`).join("\n") || ""}
|
|
|
276
2505
|
);
|
|
277
2506
|
}
|
|
278
2507
|
|
|
2508
|
+
// Generate additional workspace skills for templates that require them
|
|
2509
|
+
if (tmpl.generateSkills) {
|
|
2510
|
+
for (const skillName of tmpl.generateSkills) {
|
|
2511
|
+
const skillTmpl = SKILL_TEMPLATES[skillName];
|
|
2512
|
+
if (!skillTmpl) continue;
|
|
2513
|
+
const skillDir = path.join(ccDir, "skills", skillName);
|
|
2514
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
2515
|
+
fs.writeFileSync(
|
|
2516
|
+
path.join(skillDir, "SKILL.md"),
|
|
2517
|
+
skillTmpl.md,
|
|
2518
|
+
"utf-8",
|
|
2519
|
+
);
|
|
2520
|
+
fs.writeFileSync(
|
|
2521
|
+
path.join(skillDir, "handler.js"),
|
|
2522
|
+
skillTmpl.handler,
|
|
2523
|
+
"utf-8",
|
|
2524
|
+
);
|
|
2525
|
+
}
|
|
2526
|
+
// Create companion directory (workflows/ or templates/)
|
|
2527
|
+
const dirName = tmpl.generateDir || "workflows";
|
|
2528
|
+
const readmeContent =
|
|
2529
|
+
tmpl.generateDirReadme === "DOC_TEMPLATES_README"
|
|
2530
|
+
? DOC_TEMPLATES_README
|
|
2531
|
+
: WORKFLOW_README;
|
|
2532
|
+
const companionDir = path.join(cwd, dirName);
|
|
2533
|
+
if (!fs.existsSync(companionDir)) {
|
|
2534
|
+
fs.mkdirSync(companionDir, { recursive: true });
|
|
2535
|
+
fs.writeFileSync(
|
|
2536
|
+
path.join(companionDir, "README.md"),
|
|
2537
|
+
readmeContent,
|
|
2538
|
+
"utf-8",
|
|
2539
|
+
);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
279
2543
|
logger.success(
|
|
280
2544
|
`Initialized ChainlessChain project in ${chalk.cyan(cwd)}`,
|
|
281
2545
|
);
|
|
@@ -284,6 +2548,15 @@ ${tmpl.persona.behaviors?.map((b) => `- ${b}`).join("\n") || ""}
|
|
|
284
2548
|
logger.log(` Config: ${chalk.gray(".chainlesschain/config.json")}`);
|
|
285
2549
|
logger.log(` Rules: ${chalk.gray(".chainlesschain/rules.md")}`);
|
|
286
2550
|
logger.log(` Skills: ${chalk.gray(".chainlesschain/skills/")}`);
|
|
2551
|
+
if (tmpl.generateSkills) {
|
|
2552
|
+
const dirLabel = tmpl.generateDir || "workflows";
|
|
2553
|
+
logger.log(
|
|
2554
|
+
` Skills: ${chalk.gray(tmpl.generateSkills.map((s) => `.chainlesschain/skills/${s}/`).join(", "))}`,
|
|
2555
|
+
);
|
|
2556
|
+
logger.log(
|
|
2557
|
+
` ${dirLabel.charAt(0).toUpperCase() + dirLabel.slice(1)}: ${chalk.gray(`${dirLabel}/`)}`,
|
|
2558
|
+
);
|
|
2559
|
+
}
|
|
287
2560
|
logger.log("");
|
|
288
2561
|
logger.log(chalk.bold("Next steps:"));
|
|
289
2562
|
logger.log(
|
|
@@ -295,6 +2568,38 @@ ${tmpl.persona.behaviors?.map((b) => `- ${b}`).join("\n") || ""}
|
|
|
295
2568
|
logger.log(
|
|
296
2569
|
` ${chalk.cyan("chainlesschain agent")} Start the AI agent`,
|
|
297
2570
|
);
|
|
2571
|
+
if (tmpl.generateSkills) {
|
|
2572
|
+
logger.log("");
|
|
2573
|
+
if (template === "ai-media-creator") {
|
|
2574
|
+
logger.log(chalk.bold("AI Media Setup:"));
|
|
2575
|
+
logger.log(
|
|
2576
|
+
` ${chalk.cyan("pip install edge-tts")} Install free TTS backend`,
|
|
2577
|
+
);
|
|
2578
|
+
logger.log(
|
|
2579
|
+
` ${chalk.cyan("chainlesschain skill run comfyui-image")} \\"a sunset\\" Generate image`,
|
|
2580
|
+
);
|
|
2581
|
+
logger.log(
|
|
2582
|
+
` ${chalk.cyan("chainlesschain skill run audio-gen")} \\"Hello world\\" Synthesize speech`,
|
|
2583
|
+
);
|
|
2584
|
+
} else if (template === "ai-doc-creator") {
|
|
2585
|
+
logger.log(chalk.bold("AI Doc Setup:"));
|
|
2586
|
+
logger.log(
|
|
2587
|
+
` ${chalk.cyan("chainlesschain skill run doc-generate")} \\"My Report\\" Generate AI document`,
|
|
2588
|
+
);
|
|
2589
|
+
logger.log(
|
|
2590
|
+
` ${chalk.cyan("chainlesschain skill run libre-convert")} \\"file.docx\\" Convert to PDF`,
|
|
2591
|
+
);
|
|
2592
|
+
logger.log(
|
|
2593
|
+
` ${chalk.cyan("chainlesschain skill run doc-edit")} \\"file.md\\" Edit existing document with AI`,
|
|
2594
|
+
);
|
|
2595
|
+
logger.log(
|
|
2596
|
+
` ${chalk.cyan("winget install pandoc")} Install pandoc for DOCX output`,
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
logger.log(
|
|
2600
|
+
` ${chalk.cyan("chainlesschain cli-anything scan")} Scan for CLI tools to register`,
|
|
2601
|
+
);
|
|
2602
|
+
}
|
|
298
2603
|
logger.log("");
|
|
299
2604
|
} catch (err) {
|
|
300
2605
|
logger.error(`Failed to initialize: ${err.message}`);
|