diff --git a/web/server/agent/index.ts b/web/server/agent/index.ts index d1d4632..96baaac 100644 --- a/web/server/agent/index.ts +++ b/web/server/agent/index.ts @@ -1,6 +1,6 @@ import Anthropic from '@anthropic-ai/sdk'; import OpenAI from 'openai'; -import { tools, ToolDefinition } from './tools'; +import { tools, ToolDefinition } from './tools/index'; import { getDb } from '../db'; import fs from 'fs'; import path from 'path'; @@ -116,7 +116,7 @@ export class VideoAgent { } } - return `你是美图 Agent,一个专业的短视频创作助手。你可以帮助用户完成从创意到成片的完整流程。 + return `你是专业的短视频创作助手。你可以帮助用户完成从创意到成片的完整流程。 ## 当前可用账号 ${accountList} diff --git a/web/server/agent/tools.ts b/web/server/agent/tools.ts deleted file mode 100644 index ca9c5b3..0000000 --- a/web/server/agent/tools.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { spawn, execSync } from 'child_process'; -import path from 'path'; -import fs from 'fs'; -import os from 'os'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); -const PIPELINE_SCRIPT = path.join(PROJECT_ROOT, '.claude', 'skills', 'video-from-script', 'scripts', 'pipeline.js'); -const ACCOUNTS_DIR = path.join(PROJECT_ROOT, 'accounts'); -const OUTPUT_DIR = path.join(PROJECT_ROOT, 'output'); - -export interface ToolDefinition { - name: string; - description: string; - input_schema: { - type: 'object'; - properties: Record; - required?: string[]; - }; - execute: (params: Record) => Promise; -} - -// ============================================================================ -// Helpers -// ============================================================================ - -function loadJSON(filePath: string): unknown { - return JSON.parse(fs.readFileSync(filePath, 'utf-8')); -} - -/** Write items JSON to a temp file and invoke pipeline init, returning the manifest path */ -function runInit(params: { - account: string; - mode: string; - items: unknown[]; - imageModel?: string; - videoModel?: string; - format?: string; -}): string { - // Write items to a temp file to avoid shell escaping issues with JSON - const tmpFile = path.join(os.tmpdir(), `pipeline-items-${Date.now()}.json`); - fs.writeFileSync(tmpFile, JSON.stringify(params.items), 'utf-8'); - try { - const args = [ - `"${PIPELINE_SCRIPT}"`, 'init', - `--account "${params.account}"`, - `--mode ${params.mode}`, - `--items-file "${tmpFile}"`, - params.imageModel ? `--image-model ${params.imageModel}` : '', - params.videoModel ? `--video-model ${params.videoModel}` : '', - params.format ? `--format ${params.format}` : '', - ].filter(Boolean).join(' '); - const output = execSync(`node ${args}`, { cwd: PROJECT_ROOT, encoding: 'utf-8' }); - // Parse manifest path from init output: "Manifest 已创建: " - const match = output.match(/Manifest 已创建: (.+)/); - if (!match) throw new Error(`Failed to parse manifest path from init output: ${output}`); - return match[1].trim(); - } finally { - // Clean up temp file (pipeline init also deletes it, but be safe) - try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } - } -} - -// ============================================================================ -// Tool Registry -// ============================================================================ - -export const tools: ToolDefinition[] = [ - // --------------------------------------------------------------------------- - // Existing tools (kept exactly as before) - // --------------------------------------------------------------------------- - { - name: 'list_accounts', - description: '列出所有可用账号,返回每个账号的名称、描述、生图模型和视频模型', - input_schema: { - type: 'object', - properties: {}, - required: [], - }, - execute: async () => { - if (!fs.existsSync(ACCOUNTS_DIR)) return '暂无账号'; - const dirs = fs.readdirSync(ACCOUNTS_DIR, { withFileTypes: true }) - .filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.')) - .map((d) => { - const configPath = path.join(ACCOUNTS_DIR, d.name, 'account.json'); - if (fs.existsSync(configPath)) { - const cfg = loadJSON(configPath) as Record; - return `${d.name} - ${cfg.description || '无描述'} (生图:${cfg.imageModel} 视频:${cfg.videoModel} 画幅:${cfg.defaultFormat})`; - } - return d.name; - }); - return dirs.join('\n') || '暂无账号'; - }, - }, - { - name: 'create_account', - description: '创建新的短视频账号。需要提供账号ID、名称和描述。创建后可在 accounts/ 目录下找到配置。', - input_schema: { - type: 'object', - properties: { - id: { type: 'string', description: '账号唯一标识,英文小写,如 military-account' }, - name: { type: 'string', description: '账号显示名称,中文,如 军事账号' }, - desc: { type: 'string', description: '账号描述,说明视频风格和主题' }, - imageModel: { type: 'string', description: '生图模型: gemini, mj, gpt, kling' }, - videoModel: { type: 'string', description: '视频模型: veo3-fast, veo3-fast-frames, kling, grok' }, - format: { type: 'string', description: '画幅: 9:16 (竖屏), 16:9 (横屏), 1:1 (方形)' }, - }, - required: ['id', 'name'], - }, - execute: async (params) => { - const { id, name, desc, imageModel, videoModel, format } = params as Record; - const cmd = [ - `node "${PIPELINE_SCRIPT}" create-account`, - `--id "${id}"`, - `--name "${name}"`, - `--desc "${desc || ''}"`, - `--video-model ${videoModel || 'veo3-fast'}`, - imageModel ? `--image-model ${imageModel}` : '', - format ? `--format ${format}` : '', - ].filter(Boolean).join(' '); - const result = execSync(cmd, { cwd: PROJECT_ROOT, encoding: 'utf-8' }); - return `账号「${name}」创建成功。\n${result}`; - }, - }, - { - name: 'pipeline_status', - description: '查看指定 manifest 的 pipeline 执行进度和各阶段状态', - input_schema: { - type: 'object', - properties: { - manifest: { type: 'string', description: 'manifest.json 的绝对路径' }, - }, - required: ['manifest'], - }, - execute: async (params) => { - const { manifest } = params as { manifest: string }; - const result = execSync(`node "${PIPELINE_SCRIPT}" status --manifest "${manifest}"`, { - cwd: PROJECT_ROOT, encoding: 'utf-8', - }); - return result; - }, - }, - { - name: 'run_pipeline_phase', - description: '执行视频创作 pipeline 的指定阶段。阶段顺序: images(生图) → upload(上传) → videos(生视频) → tts(配音) → assemble(成片组装)。执行前需确认 manifest.json 已存在。', - input_schema: { - type: 'object', - properties: { - manifest: { type: 'string', description: 'manifest.json 的绝对路径' }, - phase: { type: 'string', description: '要执行的阶段: images, upload, videos, tts, assemble。多个阶段用逗号分隔如 images,upload' }, - }, - required: ['manifest', 'phase'], - }, - execute: async (params) => { - const { manifest, phase } = params as { manifest: string; phase: string }; - return new Promise((resolve, reject) => { - const proc = spawn('node', [PIPELINE_SCRIPT, 'run', '--manifest', manifest, '--phase', phase], { - cwd: PROJECT_ROOT, - env: { ...process.env }, - }); - let output = ''; - proc.stdout.on('data', (d: Buffer) => { output += d.toString(); }); - proc.stderr.on('data', (d: Buffer) => { output += d.toString(); }); - proc.on('close', (code) => { - code === 0 ? resolve(output || '执行成功') : reject(new Error(`Pipeline exit code ${code}: ${output.slice(-500)}`)); - }); - }); - }, - }, - { - name: 'get_account_config', - description: '获取指定账号的完整配置,包括模型选择、TTS语音、字幕风格等', - input_schema: { - type: 'object', - properties: { - accountId: { type: 'string', description: '账号ID,如 军事账号' }, - }, - required: ['accountId'], - }, - execute: async (params) => { - const { accountId } = params as { accountId: string }; - const configPath = path.join(ACCOUNTS_DIR, accountId, 'account.json'); - if (!fs.existsSync(configPath)) return `账号「${accountId}」不存在`; - const cfg = loadJSON(configPath); - return JSON.stringify(cfg, null, 2); - }, - }, - - // --------------------------------------------------------------------------- - // New tools: image/video generation, manifest management - // --------------------------------------------------------------------------- - { - name: 'generate_images', - description: '文生图:根据文本提示词生成图片。内部创建临时 manifest 并调用 pipeline images 阶段,支持批量生成。生成结果写入 output 目录,返回图片文件路径列表。', - input_schema: { - type: 'object', - properties: { - accountId: { type: 'string', description: '账号ID,用于继承模型、参考图等配置' }, - prompt: { type: 'string', description: '图片提示词(imagePrompt),描述要生成的图片内容' }, - count: { type: 'number', description: '生成图片数量,默认 1' }, - imageModel: { type: 'string', description: '生图模型(可选,默认继承账号配置): gemini, mj, gpt-image, kling' }, - }, - required: ['accountId', 'prompt'], - }, - execute: async (params) => { - const { accountId, prompt, count, imageModel } = params as Record; - const num = Math.max(1, Number(count) || 1); - // Build items: each item needs shotDesc + imagePrompt for the images phase - const items = Array.from({ length: num }, (_, i) => ({ - id: i + 1, - shotDesc: prompt as string, - script: '', - imagePrompt: prompt as string, - keyword: 'generated', - })); - // Init manifest (items passed as array, helper writes temp file) - const manifestPath = runInit({ - account: accountId as string, - mode: 'single', - items, - imageModel: imageModel as string | undefined, - }); - // Run images phase - execSync(`node "${PIPELINE_SCRIPT}" run --manifest "${manifestPath}" --phase images`, { - cwd: PROJECT_ROOT, encoding: 'utf-8', - }); - // Read back results - const manifest = loadJSON(manifestPath) as { items?: Array<{ id: number; file?: string; candidates?: string[]; status?: string }> }; - const results = (manifest.items || []).map((item) => ({ - id: item.id, - file: item.file || null, - candidates: item.candidates || [], - status: item.status, - })); - return JSON.stringify({ manifestPath, images: results }, null, 2); - }, - }, - { - name: 'generate_videos', - description: '图生视频:根据已有图片和提示词生成 AI 视频。内部创建临时 manifest,先上传图片再执行 videos 阶段。返回视频文件路径。', - input_schema: { - type: 'object', - properties: { - accountId: { type: 'string', description: '账号ID,用于继承视频模型等配置' }, - imagePath: { type: 'string', description: '图片文件路径(本地绝对路径或相对 PROJECT_ROOT 的路径)' }, - prompt: { type: 'string', description: '视频提示词(videoPrompt),描述图片如何动起来' }, - videoModel: { type: 'string', description: '视频模型(可选,默认继承账号配置): veo3-fast, veo3-fast-frames, kling, grok' }, - imagePrompt: { type: 'string', description: '图片提示词(可选),用于 manifest 记录' }, - }, - required: ['accountId', 'imagePath', 'prompt'], - }, - execute: async (params) => { - const { accountId, imagePath, prompt, videoModel, imagePrompt } = params as Record; - const resolvedImagePath = path.isAbsolute(imagePath) - ? imagePath - : path.resolve(PROJECT_ROOT, imagePath); - if (!fs.existsSync(resolvedImagePath)) { - return `错误: 图片文件不存在: ${resolvedImagePath}`; - } - // Determine relative path from output dir - const baseName = path.basename(resolvedImagePath); - const item = { - id: 1, - shotDesc: imagePrompt || prompt, - script: '', - imagePrompt: imagePrompt || prompt, - videoPrompt: prompt, - keyword: 'generated', - file: `images/${baseName}`, - }; - // Init manifest (items passed as array, helper writes temp file) - const manifestPath = runInit({ - account: accountId, - mode: 'single', - items: [item], - videoModel: videoModel, - }); - // Copy image into manifest's images dir - const manifestDir = path.dirname(manifestPath); - const imagesDir = path.join(manifestDir, 'images'); - if (!fs.existsSync(imagesDir)) fs.mkdirSync(imagesDir, { recursive: true }); - const targetPath = path.join(imagesDir, baseName); - if (resolvedImagePath !== targetPath) { - fs.copyFileSync(resolvedImagePath, targetPath); - } - // Run upload + videos phases - execSync(`node "${PIPELINE_SCRIPT}" run --manifest "${manifestPath}" --phase upload,videos`, { - cwd: PROJECT_ROOT, encoding: 'utf-8', - }); - // Read back results - const manifest = loadJSON(manifestPath) as { - items?: Array<{ id: number; video?: string; videoUrl?: string; videoDuration?: number; status?: string }>; - }; - const results = (manifest.items || []).map((it) => ({ - id: it.id, - video: it.video || null, - videoUrl: it.videoUrl || null, - videoDuration: it.videoDuration || null, - status: it.status, - })); - return JSON.stringify({ manifestPath, videos: results }, null, 2); - }, - }, - { - name: 'get_account_prompts', - description: '获取指定账号的三个提示词模板内容:分镜提示词、图片提示词、视频提示词。这些模板定义了账号的视频风格和创意方向。', - input_schema: { - type: 'object', - properties: { - accountId: { type: 'string', description: '账号ID' }, - }, - required: ['accountId'], - }, - execute: async (params) => { - const { accountId } = params as { accountId: string }; - const accountDir = path.join(ACCOUNTS_DIR, accountId); - if (!fs.existsSync(accountDir)) return `账号「${accountId}」不存在`; - const promptsDir = path.join(accountDir, 'prompts'); - const promptFiles = ['分镜.md', '图片提示词.md', '视频提示词.md']; - const result: Record = {}; - for (const file of promptFiles) { - const filePath = path.join(promptsDir, file); - if (fs.existsSync(filePath)) { - result[file] = fs.readFileSync(filePath, 'utf-8'); - } else { - result[file] = `(文件不存在: ${filePath})`; - } - } - return JSON.stringify(result, null, 2); - }, - }, - { - name: 'list_outputs', - description: '列出 output 目录下所有项目及其 manifest 状态,方便查看历史生成记录和进行中的任务。', - input_schema: { - type: 'object', - properties: {}, - required: [], - }, - execute: async () => { - if (!fs.existsSync(OUTPUT_DIR)) return 'output 目录不存在'; - const entries = fs.readdirSync(OUTPUT_DIR, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => { - const manifestPath = path.join(OUTPUT_DIR, d.name, 'manifest.json'); - if (!fs.existsSync(manifestPath)) return { name: d.name, status: 'no manifest' }; - try { - const manifest = loadJSON(manifestPath) as { - account?: string; - imageModel?: string; - videoModel?: string; - mode?: string; - pipeline?: { phases?: Record }; - items?: Array<{ status?: string }>; - }; - const phases = manifest.pipeline?.phases || {}; - const itemCount = manifest.items?.length || 0; - const doneCount = manifest.items?.filter((i) => i.status === 'done').length || 0; - return { - name: d.name, - account: manifest.account || '?', - mode: manifest.mode || '?', - items: `${doneCount}/${itemCount} done`, - phases: Object.entries(phases).map(([k, v]) => `${k}:${v}`).join(', ') || 'pending', - }; - } catch { - return { name: d.name, status: 'manifest parse error' }; - } - }); - return JSON.stringify(entries, null, 2); - }, - }, - { - name: 'get_manifest', - description: '读取指定 manifest.json 的完整内容,返回 JSON 字符串。', - input_schema: { - type: 'object', - properties: { - manifestPath: { type: 'string', description: 'manifest.json 的绝对路径或相对路径' }, - }, - required: ['manifestPath'], - }, - execute: async (params) => { - const { manifestPath } = params as { manifestPath: string }; - const resolved = path.isAbsolute(manifestPath) - ? manifestPath - : path.resolve(PROJECT_ROOT, manifestPath); - if (!fs.existsSync(resolved)) return `manifest 不存在: ${resolved}`; - const manifest = loadJSON(resolved); - return JSON.stringify(manifest, null, 2); - }, - }, - { - name: 'create_manifest', - description: '初始化新的 manifest.json,调用 pipeline.js init 创建输出目录和项目骨架。这是视频创作 pipeline 的起点。', - input_schema: { - type: 'object', - properties: { - accountId: { type: 'string', description: '账号ID,决定模型、参考图等配置来源' }, - mode: { type: 'string', description: '创作模式: single (单图模式) 或 framePair (首尾帧模式)' }, - items: { type: 'string', description: '分镜数据 JSON 数组字符串。每个元素需包含 shotDesc (分镜描述) 和 script (旁白文案),可选 imagePrompt、videoPrompt、directorRef、keyword、duration' }, - imageModel: { type: 'string', description: '生图模型(可选,默认继承账号配置): gemini, mj, gpt-image, kling' }, - videoModel: { type: 'string', description: '视频模型(可选,默认继承账号配置): veo3-fast, veo3-fast-frames, kling, grok' }, - format: { type: 'string', description: '画幅(可选,默认继承账号配置): 9:16, 16:9, 1:1' }, - }, - required: ['accountId', 'mode', 'items'], - }, - execute: async (params) => { - const { accountId, mode, items, imageModel, videoModel, format } = params as Record; - // Validate items JSON - let parsed: unknown; - try { parsed = JSON.parse(items); } catch { return '错误: items 不是合法的 JSON 字符串'; } - if (!Array.isArray(parsed) || (parsed as unknown[]).length === 0) return '错误: items 必须是非空 JSON 数组'; - // Write items to temp file to avoid shell escaping issues - const tmpFile = path.join(os.tmpdir(), `pipeline-items-${Date.now()}.json`); - fs.writeFileSync(tmpFile, items, 'utf-8'); - try { - const args = [ - `"${PIPELINE_SCRIPT}"`, 'init', - `--account "${accountId}"`, - `--mode ${mode}`, - `--items-file "${tmpFile}"`, - imageModel ? `--image-model ${imageModel}` : '', - videoModel ? `--video-model ${videoModel}` : '', - format ? `--format ${format}` : '', - ].filter(Boolean).join(' '); - const output = execSync(`node ${args}`, { cwd: PROJECT_ROOT, encoding: 'utf-8' }); - const match = output.match(/Manifest 已创建: (.+)/); - const manifestPath = match ? match[1].trim() : '(unable to parse path)'; - return `Manifest 已创建: ${manifestPath}\n\n${output}`; - } finally { - try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } - } - }, - }, -]; diff --git a/web/server/agent/tools/create-account.ts b/web/server/agent/tools/create-account.ts new file mode 100644 index 0000000..9349c11 --- /dev/null +++ b/web/server/agent/tools/create-account.ts @@ -0,0 +1,34 @@ +import { execSync } from 'child_process'; +import { PIPELINE_SCRIPT, PROJECT_ROOT } from './shared'; +import type { ToolDefinition } from './types'; + +export const createAccount: ToolDefinition = { + name: 'create_account', + description: '创建新的短视频账号。需要提供账号ID、名称和描述。创建后可在 accounts/ 目录下找到配置。', + input_schema: { + type: 'object', + properties: { + id: { type: 'string', description: '账号唯一标识,英文小写,如 military-account' }, + name: { type: 'string', description: '账号显示名称,中文,如 军事账号' }, + desc: { type: 'string', description: '账号描述,说明视频风格和主题' }, + imageModel: { type: 'string', description: '生图模型: gemini, mj, gpt, kling' }, + videoModel: { type: 'string', description: '视频模型: veo3-fast, veo3-fast-frames, kling, grok' }, + format: { type: 'string', description: '画幅: 9:16 (竖屏), 16:9 (横屏), 1:1 (方形)' }, + }, + required: ['id', 'name'], + }, + execute: async (params) => { + const { id, name, desc, imageModel, videoModel, format } = params as Record; + const cmd = [ + `node "${PIPELINE_SCRIPT}" create-account`, + `--id "${id}"`, + `--name "${name}"`, + `--desc "${desc || ''}"`, + `--video-model ${videoModel || 'veo3-fast'}`, + imageModel ? `--image-model ${imageModel}` : '', + format ? `--format ${format}` : '', + ].filter(Boolean).join(' '); + const result = execSync(cmd, { cwd: PROJECT_ROOT, encoding: 'utf-8' }); + return `账号「${name}」创建成功。\n${result}`; + }, +}; diff --git a/web/server/agent/tools/create-manifest.ts b/web/server/agent/tools/create-manifest.ts new file mode 100644 index 0000000..2c4ac08 --- /dev/null +++ b/web/server/agent/tools/create-manifest.ts @@ -0,0 +1,50 @@ +import path from 'path'; +import os from 'os'; +import fs from 'fs'; +import { execSync } from 'child_process'; +import { PIPELINE_SCRIPT, PROJECT_ROOT } from './shared'; +import type { ToolDefinition } from './types'; + +export const createManifest: ToolDefinition = { + name: 'create_manifest', + description: '初始化新的 manifest.json,调用 pipeline.js init 创建输出目录和项目骨架。这是视频创作 pipeline 的起点。', + input_schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: '账号ID,决定模型、参考图等配置来源' }, + mode: { type: 'string', description: '创作模式: single (单图模式) 或 framePair (首尾帧模式)' }, + items: { type: 'string', description: '分镜数据 JSON 数组字符串。每个元素需包含 shotDesc (分镜描述) 和 script (旁白文案),可选 imagePrompt、videoPrompt、directorRef、keyword、duration' }, + imageModel: { type: 'string', description: '生图模型(可选,默认继承账号配置): gemini, mj, gpt-image, kling' }, + videoModel: { type: 'string', description: '视频模型(可选,默认继承账号配置): veo3-fast, veo3-fast-frames, kling, grok' }, + format: { type: 'string', description: '画幅(可选,默认继承账号配置): 9:16, 16:9, 1:1' }, + }, + required: ['accountId', 'mode', 'items'], + }, + execute: async (params) => { + const { accountId, mode, items, imageModel, videoModel, format } = params as Record; + // Validate items JSON + let parsed: unknown; + try { parsed = JSON.parse(items); } catch { return '错误: items 不是合法的 JSON 字符串'; } + if (!Array.isArray(parsed) || (parsed as unknown[]).length === 0) return '错误: items 必须是非空 JSON 数组'; + // Write items to temp file to avoid shell escaping issues + const tmpFile = path.join(os.tmpdir(), `pipeline-items-${Date.now()}.json`); + fs.writeFileSync(tmpFile, items, 'utf-8'); + try { + const args = [ + `"${PIPELINE_SCRIPT}"`, 'init', + `--account "${accountId}"`, + `--mode ${mode}`, + `--items-file "${tmpFile}"`, + imageModel ? `--image-model ${imageModel}` : '', + videoModel ? `--video-model ${videoModel}` : '', + format ? `--format ${format}` : '', + ].filter(Boolean).join(' '); + const output = execSync(`node ${args}`, { cwd: PROJECT_ROOT, encoding: 'utf-8' }); + const match = output.match(/Manifest 已创建: (.+)/); + const manifestPath = match ? match[1].trim() : '(unable to parse path)'; + return `Manifest 已创建: ${manifestPath}\n\n${output}`; + } finally { + try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } + } + }, +}; diff --git a/web/server/agent/tools/generate-images.ts b/web/server/agent/tools/generate-images.ts new file mode 100644 index 0000000..cef13d5 --- /dev/null +++ b/web/server/agent/tools/generate-images.ts @@ -0,0 +1,46 @@ +import { execSync } from 'child_process'; +import { runInit, loadJSON, PIPELINE_SCRIPT, PROJECT_ROOT } from './shared'; +import type { ToolDefinition } from './types'; + +export const generateImages: ToolDefinition = { + name: 'generate_images', + description: '文生图:根据文本提示词生成图片。内部创建临时 manifest 并调用 pipeline images 阶段,支持批量生成。生成结果写入 output 目录,返回图片文件路径列表。', + input_schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: '账号ID,用于继承模型、参考图等配置' }, + prompt: { type: 'string', description: '图片提示词(imagePrompt),描述要生成的图片内容' }, + count: { type: 'number', description: '生成图片数量,默认 1' }, + imageModel: { type: 'string', description: '生图模型(可选,默认继承账号配置): gemini, mj, gpt-image, kling' }, + }, + required: ['accountId', 'prompt'], + }, + execute: async (params) => { + const { accountId, prompt, count, imageModel } = params as Record; + const num = Math.max(1, Number(count) || 1); + const items = Array.from({ length: num }, (_, i) => ({ + id: i + 1, + shotDesc: prompt as string, + script: '', + imagePrompt: prompt as string, + keyword: 'generated', + })); + const manifestPath = runInit({ + account: accountId as string, + mode: 'single', + items, + imageModel: imageModel as string | undefined, + }); + execSync(`node "${PIPELINE_SCRIPT}" run --manifest "${manifestPath}" --phase images`, { + cwd: PROJECT_ROOT, encoding: 'utf-8', + }); + const manifest = loadJSON(manifestPath) as { items?: Array<{ id: number; file?: string; candidates?: string[]; status?: string }> }; + const results = (manifest.items || []).map((item) => ({ + id: item.id, + file: item.file || null, + candidates: item.candidates || [], + status: item.status, + })); + return JSON.stringify({ manifestPath, images: results }, null, 2); + }, +}; diff --git a/web/server/agent/tools/generate-videos.ts b/web/server/agent/tools/generate-videos.ts new file mode 100644 index 0000000..4cc7c08 --- /dev/null +++ b/web/server/agent/tools/generate-videos.ts @@ -0,0 +1,69 @@ +import path from 'path'; +import fs from 'fs'; +import { execSync } from 'child_process'; +import { runInit, loadJSON, PIPELINE_SCRIPT, PROJECT_ROOT } from './shared'; +import type { ToolDefinition } from './types'; + +export const generateVideos: ToolDefinition = { + name: 'generate_videos', + description: '图生视频:根据已有图片和提示词生成 AI 视频。内部创建临时 manifest,先上传图片再执行 videos 阶段。返回视频文件路径。', + input_schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: '账号ID,用于继承视频模型等配置' }, + imagePath: { type: 'string', description: '图片文件路径(本地绝对路径或相对 PROJECTROOT 的路径)' }, + prompt: { type: 'string', description: '视频提示词(videoPrompt),描述图片如何动起来' }, + videoModel: { type: 'string', description: '视频模型(可选,默认继承账号配置): veo3-fast, veo3-fast-frames, kling, grok' }, + imagePrompt: { type: 'string', description: '图片提示词(可选),用于 manifest 记录' }, + }, + required: ['accountId', 'imagePath', 'prompt'], + }, + execute: async (params) => { + const { accountId, imagePath, prompt, videoModel, imagePrompt } = params as Record; + const resolvedImagePath = path.isAbsolute(imagePath) + ? imagePath + : path.resolve(PROJECT_ROOT, imagePath); + if (!fs.existsSync(resolvedImagePath)) { + return `错误: 图片文件不存在: ${resolvedImagePath}`; + } + const baseName = path.basename(resolvedImagePath); + const item = { + id: 1, + shotDesc: imagePrompt || prompt, + script: '', + imagePrompt: imagePrompt || prompt, + videoPrompt: prompt, + keyword: 'generated', + file: `images/${baseName}`, + }; + const manifestPath = runInit({ + account: accountId, + mode: 'single', + items: [item], + videoModel: videoModel, + }); + // Copy image into manifest's images dir + const manifestDir = path.dirname(manifestPath); + const imagesDir = path.join(manifestDir, 'images'); + if (!fs.existsSync(imagesDir)) fs.mkdirSync(imagesDir, { recursive: true }); + const targetPath = path.join(imagesDir, baseName); + if (resolvedImagePath !== targetPath) { + fs.copyFileSync(resolvedImagePath, targetPath); + } + // Run upload + videos phases + execSync(`node "${PIPELINE_SCRIPT}" run --manifest "${manifestPath}" --phase upload,videos`, { + cwd: PROJECT_ROOT, encoding: 'utf-8', + }); + const manifest = loadJSON(manifestPath) as { + items?: Array<{ id: number; video?: string; videoUrl?: string; videoDuration?: number; status?: string }>; + }; + const results = (manifest.items || []).map((it) => ({ + id: it.id, + video: it.video || null, + videoUrl: it.videoUrl || null, + videoDuration: it.videoDuration || null, + status: it.status, + })); + return JSON.stringify({ manifestPath, videos: results }, null, 2); + }, +}; diff --git a/web/server/agent/tools/get-account-config.ts b/web/server/agent/tools/get-account-config.ts new file mode 100644 index 0000000..05eaeba --- /dev/null +++ b/web/server/agent/tools/get-account-config.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import fs from 'fs'; +import { ACCOUNTS_DIR, loadJSON } from './shared'; +import type { ToolDefinition } from './types'; + +export const getAccountConfig: ToolDefinition = { + name: 'get_account_config', + description: '获取指定账号的完整配置,包括模型选择、TTS语音、字幕风格等', + input_schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: '账号ID,如 军事账号' }, + }, + required: ['accountId'], + }, + execute: async (params) => { + const { accountId } = params as { accountId: string }; + const configPath = path.join(ACCOUNTS_DIR, accountId, 'account.json'); + if (!fs.existsSync(configPath)) return `账号「${accountId}」不存在`; + const cfg = loadJSON(configPath); + return JSON.stringify(cfg, null, 2); + }, +}; diff --git a/web/server/agent/tools/get-account-prompts.ts b/web/server/agent/tools/get-account-prompts.ts new file mode 100644 index 0000000..e51b30e --- /dev/null +++ b/web/server/agent/tools/get-account-prompts.ts @@ -0,0 +1,33 @@ +import path from 'path'; +import fs from 'fs'; +import { ACCOUNTS_DIR } from './shared'; +import type { ToolDefinition } from './types'; + +export const getAccountPrompts: ToolDefinition = { + name: 'get_account_prompts', + description: '获取指定账号的三个提示词模板内容:分镜提示词、图片提示词、视频提示词。这些模板定义了账号的视频风格和创意方向。', + input_schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: '账号ID' }, + }, + required: ['accountId'], + }, + execute: async (params) => { + const { accountId } = params as { accountId: string }; + const accountDir = path.join(ACCOUNTS_DIR, accountId); + if (!fs.existsSync(accountDir)) return `账号「${accountId}」不存在`; + const promptsDir = path.join(accountDir, 'prompts'); + const promptFiles = ['分镜.md', '图片提示词.md', '视频提示词.md']; + const result: Record = {}; + for (const file of promptFiles) { + const filePath = path.join(promptsDir, file); + if (fs.existsSync(filePath)) { + result[file] = fs.readFileSync(filePath, 'utf-8'); + } else { + result[file] = `(文件不存在: ${filePath})`; + } + } + return JSON.stringify(result, null, 2); + }, +}; diff --git a/web/server/agent/tools/get-manifest.ts b/web/server/agent/tools/get-manifest.ts new file mode 100644 index 0000000..77e86b8 --- /dev/null +++ b/web/server/agent/tools/get-manifest.ts @@ -0,0 +1,25 @@ +import path from 'path'; +import fs from 'fs'; +import { PROJECT_ROOT, loadJSON } from './shared'; +import type { ToolDefinition } from './types'; + +export const getManifest: ToolDefinition = { + name: 'get_manifest', + description: '读取指定 manifest.json 的完整内容,返回 JSON 字符串。', + input_schema: { + type: 'object', + properties: { + manifestPath: { type: 'string', description: 'manifest.json 的绝对路径或相对路径' }, + }, + required: ['manifestPath'], + }, + execute: async (params) => { + const { manifestPath } = params as { manifestPath: string }; + const resolved = path.isAbsolute(manifestPath) + ? manifestPath + : path.resolve(PROJECT_ROOT, manifestPath); + if (!fs.existsSync(resolved)) return `manifest 不存在: ${resolved}`; + const manifest = loadJSON(resolved); + return JSON.stringify(manifest, null, 2); + }, +}; diff --git a/web/server/agent/tools/index.ts b/web/server/agent/tools/index.ts new file mode 100644 index 0000000..03c53b3 --- /dev/null +++ b/web/server/agent/tools/index.ts @@ -0,0 +1,28 @@ +import type { ToolDefinition } from './types'; +export type { ToolDefinition } from './types'; + +import { listAccounts } from './list-accounts'; +import { createAccount } from './create-account'; +import { getAccountConfig } from './get-account-config'; +import { getAccountPrompts } from './get-account-prompts'; +import { pipelineStatus } from './pipeline-status'; +import { runPipelinePhase } from './run-pipeline-phase'; +import { generateImages } from './generate-images'; +import { generateVideos } from './generate-videos'; +import { listOutputs } from './list-outputs'; +import { getManifest } from './get-manifest'; +import { createManifest } from './create-manifest'; + +export const tools: ToolDefinition[] = [ + listAccounts, + createAccount, + getAccountConfig, + getAccountPrompts, + pipelineStatus, + runPipelinePhase, + generateImages, + generateVideos, + listOutputs, + getManifest, + createManifest, +]; diff --git a/web/server/agent/tools/list-accounts.ts b/web/server/agent/tools/list-accounts.ts new file mode 100644 index 0000000..f67a115 --- /dev/null +++ b/web/server/agent/tools/list-accounts.ts @@ -0,0 +1,28 @@ +import fs from 'fs'; +import path from 'path'; +import { ACCOUNTS_DIR, loadJSON } from './shared'; +import type { ToolDefinition } from './types'; + +export const listAccounts: ToolDefinition = { + name: 'list_accounts', + description: '列出所有可用账号,返回每个账号的名称、描述、生图模型和视频模型', + input_schema: { + type: 'object', + properties: {}, + required: [], + }, + execute: async () => { + if (!fs.existsSync(ACCOUNTS_DIR)) return '暂无账号'; + const dirs = fs.readdirSync(ACCOUNTS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.')) + .map((d) => { + const configPath = path.join(ACCOUNTS_DIR, d.name, 'account.json'); + if (fs.existsSync(configPath)) { + const cfg = loadJSON(configPath) as Record; + return `${d.name} - ${cfg.description || '无描述'} (生图:${cfg.imageModel} 视频:${cfg.videoModel} 画幅:${cfg.defaultFormat})`; + } + return d.name; + }); + return dirs.join('\n') || '暂无账号'; + }, +}; diff --git a/web/server/agent/tools/list-outputs.ts b/web/server/agent/tools/list-outputs.ts new file mode 100644 index 0000000..5e1ee9c --- /dev/null +++ b/web/server/agent/tools/list-outputs.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; +import path from 'path'; +import { OUTPUT_DIR, loadJSON } from './shared'; +import type { ToolDefinition } from './types'; + +export const listOutputs: ToolDefinition = { + name: 'list_outputs', + description: '列出 output 目录下所有项目及其 manifest 状态,方便查看历史生成记录和进行中的任务。', + input_schema: { + type: 'object', + properties: {}, + required: [], + }, + execute: async () => { + if (!fs.existsSync(OUTPUT_DIR)) return 'output 目录不存在'; + const entries = fs.readdirSync(OUTPUT_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => { + const manifestPath = path.join(OUTPUT_DIR, d.name, 'manifest.json'); + if (!fs.existsSync(manifestPath)) return { name: d.name, status: 'no manifest' }; + try { + const manifest = loadJSON(manifestPath) as { + account?: string; + imageModel?: string; + videoModel?: string; + mode?: string; + pipeline?: { phases?: Record }; + items?: Array<{ status?: string }>; + }; + const phases = manifest.pipeline?.phases || {}; + const itemCount = manifest.items?.length || 0; + const doneCount = manifest.items?.filter((i) => i.status === 'done').length || 0; + return { + name: d.name, + account: manifest.account || '?', + mode: manifest.mode || '?', + items: `${doneCount}/${itemCount} done`, + phases: Object.entries(phases).map(([k, v]) => `${k}:${v}`).join(', ') || 'pending', + }; + } catch { + return { name: d.name, status: 'manifest parse error' }; + } + }); + return JSON.stringify(entries, null, 2); + }, +}; diff --git a/web/server/agent/tools/pipeline-status.ts b/web/server/agent/tools/pipeline-status.ts new file mode 100644 index 0000000..0ef214f --- /dev/null +++ b/web/server/agent/tools/pipeline-status.ts @@ -0,0 +1,22 @@ +import { execSync } from 'child_process'; +import { PIPELINE_SCRIPT, PROJECT_ROOT } from './shared'; +import type { ToolDefinition } from './types'; + +export const pipelineStatus: ToolDefinition = { + name: 'pipeline_status', + description: '查看指定 manifest 的 pipeline 执行进度和各阶段状态', + input_schema: { + type: 'object', + properties: { + manifest: { type: 'string', description: 'manifest.json 的绝对路径' }, + }, + required: ['manifest'], + }, + execute: async (params) => { + const { manifest } = params as { manifest: string }; + const result = execSync(`node "${PIPELINE_SCRIPT}" status --manifest "${manifest}"`, { + cwd: PROJECT_ROOT, encoding: 'utf-8', + }); + return result; + }, +}; diff --git a/web/server/agent/tools/run-pipeline-phase.ts b/web/server/agent/tools/run-pipeline-phase.ts new file mode 100644 index 0000000..6ba2e8e --- /dev/null +++ b/web/server/agent/tools/run-pipeline-phase.ts @@ -0,0 +1,31 @@ +import { spawn } from 'child_process'; +import { PIPELINE_SCRIPT, PROJECT_ROOT } from './shared'; +import type { ToolDefinition } from './types'; + +export const runPipelinePhase: ToolDefinition = { + name: 'run_pipeline_phase', + description: '执行视频创作 pipeline 的指定阶段。阶段顺序: images(生图) → upload(上传) → videos(生视频) → tts(配音) → assemble(成片组装)。执行前需确认 manifest.json 已存在。', + input_schema: { + type: 'object', + properties: { + manifest: { type: 'string', description: 'manifest.json 的绝对路径' }, + phase: { type: 'string', description: '要执行的阶段: images, upload, videos, tts, assemble。多个阶段用逗号分隔如 images,upload' }, + }, + required: ['manifest', 'phase'], + }, + execute: async (params) => { + const { manifest, phase } = params as { manifest: string; phase: string }; + return new Promise((resolve, reject) => { + const proc = spawn('node', [PIPELINE_SCRIPT, 'run', '--manifest', manifest, '--phase', phase], { + cwd: PROJECT_ROOT, + env: { ...process.env }, + }); + let output = ''; + proc.stdout.on('data', (d: Buffer) => { output += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { output += d.toString(); }); + proc.on('close', (code) => { + code === 0 ? resolve(output || '执行成功') : reject(new Error(`Pipeline exit code ${code}: ${output.slice(-500)}`)); + }); + }); + }, +}; diff --git a/web/server/agent/tools/shared.ts b/web/server/agent/tools/shared.ts new file mode 100644 index 0000000..ce5ac28 --- /dev/null +++ b/web/server/agent/tools/shared.ts @@ -0,0 +1,46 @@ +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import os from 'os'; +import { execSync } from 'child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +export const PIPELINE_SCRIPT = path.join(PROJECT_ROOT, '.claude', 'skills', 'video-from-script', 'scripts', 'pipeline.js'); +export const ACCOUNTS_DIR = path.join(PROJECT_ROOT, 'accounts'); +export const OUTPUT_DIR = path.join(PROJECT_ROOT, 'output'); + +export function loadJSON(filePath: string): unknown { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); +} + +export function runInit(params: { + account: string; + mode: string; + items: unknown[]; + imageModel?: string; + videoModel?: string; + format?: string; +}): string { + const tmpFile = path.join(os.tmpdir(), `pipeline-items-${Date.now()}.json`); + fs.writeFileSync(tmpFile, JSON.stringify(params.items), 'utf-8'); + try { + const args = [ + `"${PIPELINE_SCRIPT}"`, 'init', + `--account "${params.account}"`, + `--mode ${params.mode}`, + `--items-file "${tmpFile}"`, + params.imageModel ? `--image-model ${params.imageModel}` : '', + params.videoModel ? `--video-model ${params.videoModel}` : '', + params.format ? `--format ${params.format}` : '', + ].filter(Boolean).join(' '); + const output = execSync(`node ${args}`, { cwd: PROJECT_ROOT, encoding: 'utf-8' }); + const match = output.match(/Manifest 已创建: (.+)/); + if (!match) throw new Error(`Failed to parse manifest path from init output: ${output}`); + return match[1].trim(); + } finally { + try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } + } +} diff --git a/web/server/agent/tools/types.ts b/web/server/agent/tools/types.ts new file mode 100644 index 0000000..f7fceec --- /dev/null +++ b/web/server/agent/tools/types.ts @@ -0,0 +1,10 @@ +export interface ToolDefinition { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + execute: (params: Record) => Promise; +}