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.
@@ -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}`);