From eec27fbabf060cef0192080203bd8849e77573aa Mon Sep 17 00:00:00 2001 From: saturn Date: Fri, 13 Mar 2026 17:37:52 +0800 Subject: [PATCH] feat: add asset library download button, fix env ports, update README, optimize semantics, support multi-image reading, and allow voiceover analysis for silent segments --- .env.example | 19 ++- README.md | 12 +- README_en.md | 5 + .../novel-promotion/voice_analysis.en.txt | 5 +- .../novel-promotion/voice_analysis.zh.txt | 4 +- messages/en/assetHub.json | 8 +- messages/en/assets.json | 12 +- messages/en/errors.json | 3 +- messages/zh/assetHub.json | 8 +- messages/zh/assets.json | 12 +- messages/zh/errors.json | 3 +- scripts/watchdog.ts | 9 ++ seed.sql | 87 ------------ .../components/AssetLibrary.tsx | 86 ++++++++++++ .../components/assets/AssetToolbar.tsx | 89 ++++++++++++- .../components/assets/CharacterCard.tsx | 16 ++- .../components/assets/LocationCard.tsx | 9 +- .../storyboard/ImageSectionActionButtons.tsx | 7 +- .../asset-hub/components/AssetGrid.tsx | 19 ++- src/app/[locale]/workspace/asset-hub/page.tsx | 73 +++++++++++ src/app/api/projects/route.ts | 17 ++- src/components/ui/ai-edit-style.ts | 3 + src/components/ui/icons/AISparklesIcon.tsx | 22 ++++ src/lib/errors/codes.ts | 7 + src/lib/errors/normalize.ts | 41 +++++- src/lib/errors/user-messages.ts | 1 + src/lib/generators/ark.ts | 10 +- src/lib/generators/base.ts | 5 +- src/lib/logging/file-writer.ts | 73 +++++++++++ src/lib/model-gateway/openai-compat/image.ts | 56 ++++++-- .../openai-compat/template-image.ts | 48 ++++--- .../handlers/script-to-storyboard-helpers.ts | 6 +- .../workers/handlers/script-to-storyboard.ts | 22 +++- .../workers/handlers/voice-analyze-helpers.ts | 6 +- src/lib/workers/handlers/voice-analyze.ts | 22 +++- src/lib/workers/utils.ts | 124 ++++++++++++++++++ .../generators/image-provider-smoke.test.ts | 30 +++++ ...-compat-template-image-output-urls.test.ts | 98 ++++++++++++++ .../unit/worker/script-to-storyboard.test.ts | 33 ++++- tests/unit/worker/voice-analyze.test.ts | 33 ++++- .../worker/voice-line-parse-helpers.test.ts | 21 +++ 41 files changed, 977 insertions(+), 187 deletions(-) delete mode 100644 seed.sql create mode 100644 src/components/ui/ai-edit-style.ts create mode 100644 src/components/ui/icons/AISparklesIcon.tsx create mode 100644 tests/unit/model-gateway/openai-compat-template-image-output-urls.test.ts create mode 100644 tests/unit/worker/voice-line-parse-helpers.test.ts diff --git a/.env.example b/.env.example index 7f06442..41646d9 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # ==================== 数据库 ==================== -# Docker 模式下无需修改,docker-compose.yml 会自动覆盖 -DATABASE_URL="mysql://root:waoowaoo123@localhost:3306/waoowaoo" +# 本地开发模式:docker-compose.yml 将 MySQL 映射到宿主机的 13306 端口 +# Docker 容器模式:docker-compose.yml 会自动覆盖此配置 +DATABASE_URL="mysql://root:waoowaoo123@localhost:13306/waoowaoo" # ==================== 存储 ==================== # minio: S3 兼容对象存储(默认) @@ -9,7 +10,8 @@ DATABASE_URL="mysql://root:waoowaoo123@localhost:3306/waoowaoo" STORAGE_TYPE=minio # MinIO / S3 兼容存储配置 -MINIO_ENDPOINT=http://localhost:9000 +# 本地开发模式:docker-compose.yml 将 MinIO 映射到宿主机的 19000 端口 +MINIO_ENDPOINT=http://localhost:19000 MINIO_REGION=us-east-1 MINIO_BUCKET=waoowaoo MINIO_ACCESS_KEY=minioadmin @@ -23,18 +25,21 @@ MINIO_FORCE_PATH_STYLE=true # COS_REGION= # ==================== 认证 ==================== -NEXTAUTH_URL=https://localhost +# 本地开发模式(方式三):使用 http://localhost:3000 +# Docker 容器模式(方式一、二):改为 https://localhost(配合 Caddy)或 http://localhost:13000 +NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET=please-change-this-to-a-random-string # ==================== 内部密钥 ==================== CRON_SECRET=please-change-this-cron-secret INTERNAL_TASK_TOKEN=please-change-this-task-token -API_ENCRYPTION_KEY=please-change-this-encryption-key +API_ENCRYPTION_KEY=waoowaoo-opensource-fixed-key-2026 # ==================== Redis ==================== -# Docker 模式下无需修改,docker-compose.yml 会自动覆盖 +# 本地开发模式:docker-compose.yml 将 Redis 映射到宿主机的 16379 端口 +# Docker 容器模式:docker-compose.yml 会自动覆盖此配置 REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 +REDIS_PORT=16379 REDIS_USERNAME= REDIS_PASSWORD= REDIS_TLS= diff --git a/README.md b/README.md index f616ed0..76a4da1 100644 --- a/README.md +++ b/README.md @@ -81,18 +81,28 @@ docker compose down && docker compose up -d --build ```bash git clone https://github.com/saturndec/waoowaoo.git cd waoowaoo + +# 复制环境变量配置文件(必须在 npm install 之前完成) +cp .env.example .env +# ⚠️ 编辑 .env,填入你的 AI API Key(NEXTAUTH_URL 默认已是 http://localhost:3000,无需修改) + npm install # 只启动基础设施 +# 注意:docker-compose.yml 将服务映射到非标准端口,.env.example 已按此预设 +mysql:13306 redis:16379 minio:19000 docker compose up mysql redis minio -d -# 运行数据库迁移 +# 初始化数据库表结构(首次必须执行,跳过会导致启动后报错) npx prisma db push # 启动开发服务器 npm run dev ``` +> [!WARNING] +> 跳过 `npx prisma db push` 会导致所有数据库表不存在,启动后报错 `The table 'tasks' does not exist`。请务必先运行此命令再启动开发服务器。 + --- 访问 [http://localhost:13000](http://localhost:13000)(方式一、二)或 [http://localhost:3000](http://localhost:3000)(方式三)开始使用! diff --git a/README_en.md b/README_en.md index aa63ec0..555306e 100644 --- a/README_en.md +++ b/README_en.md @@ -73,6 +73,11 @@ docker compose down && docker compose up -d --build ```bash git clone https://github.com/saturndec/waoowaoo.git cd waoowaoo + +# Copy environment config (must be done before npm install) +cp .env.example .env +# ⚠️ Edit .env to fill in your AI API Keys (NEXTAUTH_URL defaults to http://localhost:3000, no change needed) + npm install # Start infrastructure only diff --git a/lib/prompts/novel-promotion/voice_analysis.en.txt b/lib/prompts/novel-promotion/voice_analysis.en.txt index cd459db..a9b9ab5 100644 --- a/lib/prompts/novel-promotion/voice_analysis.en.txt +++ b/lib/prompts/novel-promotion/voice_analysis.en.txt @@ -34,5 +34,6 @@ Rules: 4. Match panel by order + speaker consistency + semantic relevance. 5. If no reliable panel match exists, set "matchedPanel": null. 6. Use canonical names from character library when possible. -7. Return strict JSON only, no markdown. -8. ⚠️ JSON SAFETY: All quotation marks in dialogue (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values. +7. If there is no spoken dialogue that should be voiced, return []. +8. Return strict JSON only, no markdown. +9. ⚠️ JSON SAFETY: All quotation marks in dialogue (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values. diff --git a/lib/prompts/novel-promotion/voice_analysis.zh.txt b/lib/prompts/novel-promotion/voice_analysis.zh.txt index 6274959..c36fd9b 100644 --- a/lib/prompts/novel-promotion/voice_analysis.zh.txt +++ b/lib/prompts/novel-promotion/voice_analysis.zh.txt @@ -28,8 +28,10 @@ - 动作描写(描述角色的动作) - 场景描述(描述环境、画面) - 章节标题 - + - 明确设定为无语言、默片、纯画面表达的内容 + ⚠️ 判断标准:这句话是否需要有人"说出来"?如果只是描述画面动作,不要提取。 + ⚠️ 如果全文没有任何需要配音的台词,直接返回 []。 2. 【情绪强度 emotionStrength】 根据台词的情绪激烈程度,输出0.1-0.5之间的数值(⚠️ 注意:最高不超过0.5,保持语音自然平稳): diff --git a/messages/en/assetHub.json b/messages/en/assetHub.json index 9813edc..b32cea4 100644 --- a/messages/en/assetHub.json +++ b/messages/en/assetHub.json @@ -13,6 +13,12 @@ "addCharacter": "Add Character", "addLocation": "Add Location", "addVoice": "Add Voice", + "downloadAll": "Download All", + "downloadAllTitle": "Download All Image Assets as ZIP", + "downloading": "Packing...", + "downloadSuccess": "Download Complete", + "downloadFailed": "Download Failed", + "downloadEmpty": "No image assets to download", "newFolder": "New Folder", "editFolder": "Edit Folder", "deleteFolder": "Delete Folder", @@ -98,4 +104,4 @@ "dropOrClick": "Drop image or click to upload", "supportedFormats": "JPG, PNG supported" } -} +} \ No newline at end of file diff --git a/messages/en/assets.json b/messages/en/assets.json index 94794e0..a7146c0 100644 --- a/messages/en/assets.json +++ b/messages/en/assets.json @@ -85,6 +85,9 @@ "selectCount": "Select generation count", "generateCountPrefix": "Generate", "generateCountSuffix": "images", + "regenCountPrefix": "Regenerate", + "regenCountSuffix": "", + "regenCountAriaLabel": "Select regeneration count", "generatedProgress": "Generated {generated}/{total}", "generating": "Generating", "regenerating": "Regenerating", @@ -188,7 +191,8 @@ "regenerateAll": "Regenerate All", "regenerateAllConfirm": "Regenerate images for all assets? This will overwrite existing images.", "noAssetsToGenerate": "No assets available for generation", - "regenerateAllHint": "Regenerate all asset images (overwrite existing)" + "regenerateAllHint": "Regenerate all asset images (overwrite existing)", + "downloadAll": "Download all images as ZIP" }, "common": { "actions": "Actions", @@ -241,7 +245,9 @@ "copySuccessCharacter": "Character appearance copied successfully", "copySuccessLocation": "Location image copied successfully", "copySuccessVoice": "Voice copied successfully", - "copyFailed": "Copy failed: {error}" + "copyFailed": "Copy failed: {error}", + "downloadEmpty": "No image assets to download", + "downloadFailed": "Download failed" }, "tts": { "voiceDesignSaved": "AI-designed voice has been set for {name}", @@ -326,4 +332,4 @@ "referenceImagesHint": "(optional, paste supported)", "startEditing": "Start Editing" } -} +} \ No newline at end of file diff --git a/messages/en/errors.json b/messages/en/errors.json index 6683ebd..c63e865 100644 --- a/messages/en/errors.json +++ b/messages/en/errors.json @@ -6,6 +6,7 @@ "RATE_LIMIT": "Too many requests. Please retry in {retryAfter} seconds", "MODEL_NOT_OPEN": "Model permission is not activated. Go to https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model and click \"Activate all models\" in the top-right of Model Management", "MODEL_NOT_REGISTERED": "Model is not registered. Add an available model in configuration first", + "MODEL_NOT_CONFIGURED": "No model configured. Please go to Settings and add the required model type before generating.", "QUOTA_EXCEEDED": "Quota exceeded. Please try again later", "GENERATION_FAILED": "Generation failed. Please retry", "GENERATION_TIMEOUT": "Generation timed out. Please retry", @@ -19,4 +20,4 @@ "TASK_NOT_READY": "Task is still processing", "NO_RESULT": "Task has no result", "CONFLICT": "Resource state conflict" -} +} \ No newline at end of file diff --git a/messages/zh/assetHub.json b/messages/zh/assetHub.json index 6e584bc..36940e0 100644 --- a/messages/zh/assetHub.json +++ b/messages/zh/assetHub.json @@ -13,6 +13,12 @@ "addCharacter": "新建角色", "addLocation": "新建场景", "addVoice": "新建音色", + "downloadAll": "打包下载", + "downloadAllTitle": "下载全部图片资产", + "downloading": "打包中...", + "downloadSuccess": "下载完成", + "downloadFailed": "下载失败", + "downloadEmpty": "当前没有可下载的图片资产", "newFolder": "新建文件夹", "editFolder": "编辑文件夹", "deleteFolder": "删除文件夹", @@ -98,4 +104,4 @@ "dropOrClick": "拖放图片或点击上传", "supportedFormats": "支持 JPG、PNG 格式" } -} +} \ No newline at end of file diff --git a/messages/zh/assets.json b/messages/zh/assets.json index e356a77..86c9e49 100644 --- a/messages/zh/assets.json +++ b/messages/zh/assets.json @@ -85,6 +85,9 @@ "selectCount": "选择生成数量", "generateCountPrefix": "生成", "generateCountSuffix": "张图像", + "regenCountPrefix": "重新生成", + "regenCountSuffix": "张", + "regenCountAriaLabel": "选择重新生成张数", "generatedProgress": "已生成 {generated}/{total}", "generating": "生成中", "regenerating": "重新生成中", @@ -188,7 +191,8 @@ "regenerateAll": "重新生成全部", "regenerateAllConfirm": "确定要重新生成所有资产的图片吗?这将覆盖现有图片。", "noAssetsToGenerate": "没有可生成的资产", - "regenerateAllHint": "重新生成所有资产图片(覆盖现有)" + "regenerateAllHint": "重新生成所有资产图片(覆盖现有)", + "downloadAll": "打包下载全部图片" }, "common": { "actions": "操作", @@ -241,7 +245,9 @@ "copySuccessCharacter": "角色形象复制成功", "copySuccessLocation": "场景图片复制成功", "copySuccessVoice": "音色复制成功", - "copyFailed": "复制失败: {error}" + "copyFailed": "复制失败: {error}", + "downloadEmpty": "当前没有可下载的图片资产", + "downloadFailed": "打包下载失败" }, "tts": { "voiceDesignSaved": "已为 {name} 设置 AI 设计的声音", @@ -326,4 +332,4 @@ "referenceImagesHint": "(可选,支持粘贴)", "startEditing": "开始编辑" } -} +} \ No newline at end of file diff --git a/messages/zh/errors.json b/messages/zh/errors.json index d6e5d5a..e8b3348 100644 --- a/messages/zh/errors.json +++ b/messages/zh/errors.json @@ -6,6 +6,7 @@ "RATE_LIMIT": "请求过于频繁,请 {retryAfter} 秒后重试", "MODEL_NOT_OPEN": "模型权限未开通。请前往 https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model ,在模型管理页面点击右上角「一键开通所有模型」", "MODEL_NOT_REGISTERED": "模型未注册,请先在配置中添加可用模型", + "MODEL_NOT_CONFIGURED": "未配置可用模型,请先前往「设置」页面添加对应类型的模型后再试", "QUOTA_EXCEEDED": "配额已用尽,请稍后重试", "GENERATION_FAILED": "生成失败,请重试", "GENERATION_TIMEOUT": "生成超时,请重试", @@ -19,4 +20,4 @@ "TASK_NOT_READY": "任务正在处理中", "NO_RESULT": "任务无结果", "CONFLICT": "资源状态冲突" -} +} \ No newline at end of file diff --git a/scripts/watchdog.ts b/scripts/watchdog.ts index 593daf3..b36efc6 100644 --- a/scripts/watchdog.ts +++ b/scripts/watchdog.ts @@ -5,10 +5,14 @@ import { resolveTaskLocaleFromBody } from '@/lib/task/resolve-locale' import { markTaskFailed } from '@/lib/task/service' import { publishTaskEvent } from '@/lib/task/publisher' import { TASK_EVENT_TYPE, TASK_TYPE, type TaskType } from '@/lib/task/types' +import { cleanupAllProjectLogs } from '@/lib/logging/file-writer' const INTERVAL_MS = Number.parseInt(process.env.WATCHDOG_INTERVAL_MS || '30000', 10) || 30000 const HEARTBEAT_TIMEOUT_MS = Number.parseInt(process.env.TASK_HEARTBEAT_TIMEOUT_MS || '90000', 10) || 90000 const TASK_TYPE_SET: ReadonlySet = new Set(Object.values(TASK_TYPE)) +// 每小时执行一次日志清理 +const LOG_CLEANUP_INTERVAL_TICKS = Math.ceil(3600_000 / INTERVAL_MS) +let tickCount = 0 const logger = createScopedLogger({ module: 'watchdog', action: 'watchdog.tick', @@ -181,10 +185,15 @@ async function cleanupZombieProcessingTasks() { } async function tick() { + tickCount++ const startedAt = Date.now() try { await recoverQueuedTasks() await cleanupZombieProcessingTasks() + // 每小时清理一次日志(过滤 24h 前内容) + if (tickCount % LOG_CLEANUP_INTERVAL_TICKS === 0) { + void cleanupAllProjectLogs() + } logger.info({ action: 'watchdog.tick.ok', message: 'watchdog tick completed', diff --git a/seed.sql b/seed.sql deleted file mode 100644 index cc8731c..0000000 --- a/seed.sql +++ /dev/null @@ -1,87 +0,0 @@ --- ============================================================================ --- waoowaoo 设置中心 API 配置 Seed Data --- ============================================================================ --- 使用说明: --- 1. 确保 API_ENCRYPTION_KEY 环境变量设置为: waoowaoo-opensource-fixed-key-2026 --- (与 docker-compose.yml 和 .env.example 中的默认值一致) --- 2. 此种子数据会为指定用户创建/更新完整的 API 配置,包括: --- - 所有 Provider(含加密的 API Key) --- - 所有 Model(53个模型,覆盖 LLM/Image/Video/Audio/LipSync) --- - 默认模型选择 --- - Capability 默认参数 --- 3. 执行方式: --- mysql -h 127.0.0.1 -P 13306 -u root -pwaoowaoo123 waoowaoo < prisma/seed.sql --- --- ⚠️ 重要提醒: --- - API Key 使用 AES-256-GCM 加密,密钥从 API_ENCRYPTION_KEY 派生 --- - 必须保证目标环境的 API_ENCRYPTION_KEY 与加密时一致,否则无法解密 --- - 此文件包含敏感信息(加密的 API Key),请勿公开提交到公开仓库 --- ============================================================================ - --- 替换为目标用户的 userId(可通过 SELECT id FROM user WHERE name='<手机号>'; 查询) -SET @target_user_id = (SELECT id FROM user LIMIT 1); - --- 如果找不到用户则中止 -SELECT IF(@target_user_id IS NULL, 'ERROR: 未找到任何用户,请先注册登录后再执行此脚本', CONCAT('目标用户: ', @target_user_id)) AS status; - --- Upsert: 如果用户已有配置则更新,否则插入 -INSERT INTO user_preferences ( - id, - userId, - analysisModel, - characterModel, - locationModel, - storyboardModel, - editModel, - videoModel, - lipSyncModel, - videoRatio, - videoResolution, - artStyle, - ttsRate, - imageResolution, - customProviders, - customModels, - capabilityDefaults, - createdAt, - updatedAt -) VALUES ( - UUID(), - @target_user_id, - 'openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::gpt-5.2', - 'gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534::gemini-3.1-flash-image-preview', - 'openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::gpt-image-1.5-all', - 'gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534::gemini-3.1-flash-image-preview', - 'gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534::gemini-3.1-flash-image-preview', - 'openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::veo3.1-4k', - 'fal::fal-ai/kling-video/lipsync/audio-to-video', - '9:16', - '1080p', - 'realistic', - '+100%', - '2K', - '[{"id":"ark","name":"火山引擎 Ark","apiKey":"af777ef068f9f5d89de1cd6913e7e8a1:380a08716d627e587e105a60708f632a:ff19f2c1a4ce56e5f09848701b95998eb1ab5beb0207bbff74b00077dd41ee843624812d"},{"id":"google","name":"Google AI Studio","apiKey":"0ff5ccc1305d36968936ba84022b7230:fa1b5b7099228606968c3cc9f13763cb:f010dab761bfd5b022954dc4c1340aac2043f76bbef26d34b6915b7a1e5e7a7da02c4fde301f44"},{"id":"openrouter","name":"OpenRouter","baseUrl":"https://openrouter.ai/api/v1","apiKey":"5a458e0e4f3078adde030872ca0f8c76:8ac2631532d445f9283103489234cb41:a0886c0d2ea9d7717c5e24cf9a3edf7010da0537d301c48d44b1f4cfd564d666fc3116b8a207b3915d58fc28a9334c58af1decc6963501ca94062a07b9b156b7145d9dc7df156ea62b"},{"id":"minimax","name":"海螺 MiniMax","apiKey":"cd478d3206d51d2202733d53af36545f:c701cbc753ba003b6a7d8497ddfe1af7:94173b1add028a8992e1869c2179dbca11067f9b8fe862c6ac659513a99064b80a55e1be895c4b59df8d54c7f85febaf10c55a6f38bd1760507c46857f3f86e38c0619106b714405e48db67a3988563f5e701fed2fa3fe6cdd65a5a0984a5eec5f42884bf1f6e3f9548d7f40f0a073d42df8079cb36649dcdb7dd251bd4f"},{"id":"vidu","name":"生数科技 Vidu"},{"id":"fal","name":"FAL","apiKey":"ff46a21e20c361c17d26559b14fa73af:b01c988627f4a1f4238c588e10687c5b:e41aa7be700980b0a8bca296d412c81adfe4a419b12b37436f826aa18e84d167e215e25a6ee5a2787a3827c1e76b0cc63e783b11f5d32bd5b2a6e633e9d31f444bdac69961"},{"id":"qwen","name":"Qwen","apiKey":"68aaecff41230b1476c583cd36f24e25:87575eba9d9ac1295acbb373602158b9:a28c360ecbbebb887ef49094df56cd5ca11d66a62b3fbc61dc48dbc98fc2abef04ce52"},{"id":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534","name":"yunwu","baseUrl":"https://yunwu.ai","apiKey":"f70d1f9b8c1b8e531e20bd29704ba8ac:0bc9cf65ebfdb5b835198eda1d6a8e1d:ab5ef06b20d7896a17481bb4876a8f409f2aba4688774ea781a3bdc01a42243ec70b55d77dc25a397989d88d02ecc221212299"},{"id":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0","name":"yunwu","baseUrl":"https://yunwu.ai","apiMode":"openai-official","apiKey":"70ac28b7d7b16caec92e29e03b2b1581:8d6413eebca1da054911a295455f7549:f39e290a1e48eb951868d3a91378a665b818c5228d951cfa96293985ff6f23b4319bf8668c5c5d2ae6556bfa78218a9033d1a0"},{"id":"openai-compatible:6dccfc93-ff9a-4247-aea8-d82eed121ea3","name":"yunwu","baseUrl":"https://yunwu.ai","apiMode":"openai-official","apiKey":"8b32e6e910634865a6dda2b91078e8d6:dc1846f5bf1fd942ff24640c530da875:69d769c95f7feb461fbca2cd70daaf576f9451a6bb0b6f08dabe28fe16f9bab0f96d4d7c3fcd91bdab45dfd5a8178c85955527"}]', - '[{"modelId":"google/gemini-3.1-pro-preview","modelKey":"openrouter::google/gemini-3.1-pro-preview","name":"Gemini 3.1 Pro","type":"llm","provider":"openrouter","price":0},{"modelId":"google/gemini-3-pro-preview","modelKey":"openrouter::google/gemini-3-pro-preview","name":"Gemini 3 Pro","type":"llm","provider":"openrouter","price":0},{"modelId":"google/gemini-3-flash-preview","modelKey":"openrouter::google/gemini-3-flash-preview","name":"Gemini 3 Flash","type":"llm","provider":"openrouter","price":0},{"modelId":"anthropic/claude-sonnet-4.5","modelKey":"openrouter::anthropic/claude-sonnet-4.5","name":"Claude Sonnet 4.5","type":"llm","provider":"openrouter","price":0},{"modelId":"gemini-3.1-pro-preview","modelKey":"google::gemini-3.1-pro-preview","name":"Gemini 3.1 Pro","type":"llm","provider":"google","price":0},{"modelId":"gemini-3-pro-preview","modelKey":"google::gemini-3-pro-preview","name":"Gemini 3 Pro","type":"llm","provider":"google","price":0},{"modelId":"gemini-3-flash-preview","modelKey":"google::gemini-3-flash-preview","name":"Gemini 3 Flash","type":"llm","provider":"google","price":0},{"modelId":"doubao-seed-2-0-pro-260215","modelKey":"ark::doubao-seed-2-0-pro-260215","name":"Doubao Seed 2.0 Pro","type":"llm","provider":"ark","price":0},{"modelId":"doubao-seed-2-0-lite-260215","modelKey":"ark::doubao-seed-2-0-lite-260215","name":"Doubao Seed 2.0 Lite","type":"llm","provider":"ark","price":0},{"modelId":"doubao-seed-2-0-mini-260215","modelKey":"ark::doubao-seed-2-0-mini-260215","name":"Doubao Seed 2.0 Mini","type":"llm","provider":"ark","price":0},{"modelId":"banana","modelKey":"fal::banana","name":"Banana Pro","type":"image","provider":"fal","price":0},{"modelId":"doubao-seedream-4-5-251128","modelKey":"ark::doubao-seedream-4-5-251128","name":"Seedream 4.5","type":"image","provider":"ark","price":0},{"modelId":"doubao-seedream-4-0-250828","modelKey":"ark::doubao-seedream-4-0-250828","name":"Seedream 4.0","type":"image","provider":"ark","price":0},{"modelId":"gemini-3.1-flash-image-preview","modelKey":"google::gemini-3.1-flash-image-preview","name":"Nano Banana 2","type":"image","provider":"google","price":0},{"modelId":"doubao-seedance-1-0-pro-fast-251015","modelKey":"ark::doubao-seedance-1-0-pro-fast-251015","name":"Seedance 1.0 Pro Fast","type":"video","provider":"ark","price":0},{"modelId":"doubao-seedance-1-0-lite-i2v-250428","modelKey":"ark::doubao-seedance-1-0-lite-i2v-250428","name":"Seedance 1.0 Lite","type":"video","provider":"ark","price":0},{"modelId":"doubao-seedance-1-5-pro-251215","modelKey":"ark::doubao-seedance-1-5-pro-251215","name":"Seedance 1.5 Pro","type":"video","provider":"ark","price":0},{"modelId":"doubao-seedance-1-0-pro-250528","modelKey":"ark::doubao-seedance-1-0-pro-250528","name":"Seedance 1.0 Pro","type":"video","provider":"ark","price":0},{"modelId":"veo-3.1-generate-preview","modelKey":"google::veo-3.1-generate-preview","name":"Veo 3.1","type":"video","provider":"google","price":0},{"modelId":"veo-3.1-fast-generate-preview","modelKey":"google::veo-3.1-fast-generate-preview","name":"Veo 3.1 Fast","type":"video","provider":"google","price":0},{"modelId":"veo-3.0-generate-001","modelKey":"google::veo-3.0-generate-001","name":"Veo 3.0","type":"video","provider":"google","price":0},{"modelId":"veo-3.0-fast-generate-001","modelKey":"google::veo-3.0-fast-generate-001","name":"Veo 3.0 Fast","type":"video","provider":"google","price":0},{"modelId":"veo-2.0-generate-001","modelKey":"google::veo-2.0-generate-001","name":"Veo 2.0","type":"video","provider":"google","price":0},{"modelId":"fal-wan25","modelKey":"fal::fal-wan25","name":"Wan 2.6","type":"video","provider":"fal","price":0},{"modelId":"fal-veo31","modelKey":"fal::fal-veo31","name":"Veo 3.1","type":"video","provider":"fal","price":0},{"modelId":"fal-sora2","modelKey":"fal::fal-sora2","name":"Sora 2","type":"video","provider":"fal","price":0},{"modelId":"fal-ai/kling-video/v2.5-turbo/pro/image-to-video","modelKey":"fal::fal-ai/kling-video/v2.5-turbo/pro/image-to-video","name":"Kling 2.5 Turbo Pro","type":"video","provider":"fal","price":0},{"modelId":"fal-ai/kling-video/v3/standard/image-to-video","modelKey":"fal::fal-ai/kling-video/v3/standard/image-to-video","name":"Kling 3 Standard","type":"video","provider":"fal","price":0},{"modelId":"fal-ai/kling-video/v3/pro/image-to-video","modelKey":"fal::fal-ai/kling-video/v3/pro/image-to-video","name":"Kling 3 Pro","type":"video","provider":"fal","price":0},{"modelId":"fal-ai/index-tts-2/text-to-speech","modelKey":"fal::fal-ai/index-tts-2/text-to-speech","name":"IndexTTS 2","type":"audio","provider":"fal","price":0},{"modelId":"fal-ai/kling-video/lipsync/audio-to-video","modelKey":"fal::fal-ai/kling-video/lipsync/audio-to-video","name":"Kling Lip Sync","type":"lipsync","provider":"fal","price":0},{"modelId":"vidu-lipsync","modelKey":"vidu::vidu-lipsync","name":"Vidu Lip Sync","type":"lipsync","provider":"vidu","price":0},{"modelId":"minimax-hailuo-2.3","modelKey":"minimax::minimax-hailuo-2.3","name":"Hailuo 2.3","type":"video","provider":"minimax","price":0},{"modelId":"minimax-hailuo-2.3-fast","modelKey":"minimax::minimax-hailuo-2.3-fast","name":"Hailuo 2.3 Fast","type":"video","provider":"minimax","price":0},{"modelId":"viduq3-pro","modelKey":"vidu::viduq3-pro","name":"Vidu Q3 Pro","type":"video","provider":"vidu","price":0},{"modelId":"viduq2-pro-fast","modelKey":"vidu::viduq2-pro-fast","name":"Vidu Q2 Pro Fast","type":"video","provider":"vidu","price":0},{"modelId":"viduq2-pro","modelKey":"vidu::viduq2-pro","name":"Vidu Q2 Pro","type":"video","provider":"vidu","price":0},{"modelId":"viduq2-turbo","modelKey":"vidu::viduq2-turbo","name":"Vidu Q2 Turbo","type":"video","provider":"vidu","price":0},{"modelId":"viduq1","modelKey":"vidu::viduq1","name":"Vidu Q1","type":"video","provider":"vidu","price":0},{"modelId":"viduq1-classic","modelKey":"vidu::viduq1-classic","name":"Vidu Q1 Classic","type":"video","provider":"vidu","price":0},{"modelId":"vidu2.0","modelKey":"vidu::vidu2.0","name":"Vidu 2.0","type":"video","provider":"vidu","price":0},{"modelId":"fal-kling25","modelKey":"fal::fal-kling25","name":"Kling 2.6","type":"video","provider":"fal","price":0},{"modelId":"gemini-3-pro-image-preview","modelKey":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534::gemini-3-pro-image-preview","name":"Banana Pro","type":"image","provider":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534","price":0},{"modelId":"gemini-3.1-pro-preview","modelKey":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534::gemini-3.1-pro-preview","name":"Gemini 3.1 Pro","type":"llm","provider":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534","price":0},{"modelId":"gemini-3-flash-preview","modelKey":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534::gemini-3-flash-preview","name":"Gemini 3 Flash","type":"llm","provider":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534","price":0},{"modelId":"gemini-3-pro-preview","modelKey":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534::gemini-3-pro-preview","name":"Gemini 3 Pro","type":"llm","provider":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534","price":0},{"modelId":"qwen/qwen3.5-397b-a17b","modelKey":"openrouter::qwen/qwen3.5-397b-a17b","name":"qwen/qwen3.5-397b-a17b","type":"llm","provider":"openrouter","price":0,"customPricing":{"llm":{"inputPerMillion":3,"outputPerMillion":22}}},{"modelId":"gemini-3.1-flash-image-preview","modelKey":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534::gemini-3.1-flash-image-preview","name":"Nano Banana 2","type":"image","provider":"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534","price":0},{"modelId":"claude-sonnet-4-6","modelKey":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::claude-sonnet-4-6","name":"claude46","type":"llm","provider":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0","price":0},{"modelId":"veo_3_1-fast-4K","modelKey":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::veo_3_1-fast-4K","name":"veo3.1fast","type":"video","provider":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0","price":0},{"modelId":"gpt-5.2","modelKey":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::gpt-5.2","name":"gpt-5.2","type":"llm","provider":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0","price":0},{"modelId":"gpt-image-1.5-all","modelKey":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::gpt-image-1.5-all","name":"gpt-image-1.5-all","type":"image","provider":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0","price":0},{"modelId":"veo3.1-4k","modelKey":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::veo3.1-4k","name":"veo3.1-4k create","type":"video","provider":"openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0","price":0}]', - '{"ark::doubao-seedance-1-0-pro-fast-251015":{"duration":4},"ark::doubao-seedance-1-0-lite-i2v-250428":{"resolution":"720p"},"gemini-compatible:7023dae7-9124-4895-abb4-b966e5a00534::gemini-3-pro-image-preview":{"resolution":"2K"}}', - NOW(), - NOW() -) -ON DUPLICATE KEY UPDATE - analysisModel = VALUES(analysisModel), - characterModel = VALUES(characterModel), - locationModel = VALUES(locationModel), - storyboardModel = VALUES(storyboardModel), - editModel = VALUES(editModel), - videoModel = VALUES(videoModel), - lipSyncModel = VALUES(lipSyncModel), - videoRatio = VALUES(videoRatio), - videoResolution = VALUES(videoResolution), - artStyle = VALUES(artStyle), - ttsRate = VALUES(ttsRate), - imageResolution = VALUES(imageResolution), - customProviders = VALUES(customProviders), - customModels = VALUES(customModels), - capabilityDefaults = VALUES(capabilityDefaults), - updatedAt = NOW(); - -SELECT '✅ API 配置导入成功!请刷新设置中心页面查看。' AS result; diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetLibrary.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetLibrary.tsx index 20006aa..7fe3d9d 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetLibrary.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetLibrary.tsx @@ -12,6 +12,9 @@ import { useState } from 'react' import { useTranslations } from 'next-intl' import AssetsStage from './AssetsStage' import { AppIcon } from '@/components/ui/icons' +import { useProjectAssets } from '@/lib/query/hooks' +import JSZip from 'jszip' +import { logError as _logError } from '@/lib/logging/core' interface AssetLibraryProps { projectId: string @@ -23,8 +26,77 @@ export default function AssetLibrary({ isAnalyzingAssets }: AssetLibraryProps) { const [isOpen, setIsOpen] = useState(false) + const [isDownloading, setIsDownloading] = useState(false) const t = useTranslations('assets') + // 获取项目资产数据用于下载 + const { data: assets } = useProjectAssets(projectId) + + const handleDownloadAll = async () => { + const characters = assets?.characters ?? [] + const locations = assets?.locations ?? [] + + // 收集所有有效图片 + const imageEntries: Array<{ filename: string; url: string }> = [] + + // 角色图片 + for (const character of characters) { + for (const appearance of character.appearances ?? []) { + const url = appearance.imageUrl + if (!url) continue + const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_') + const filename = appearance.appearanceIndex === 0 + ? `characters/${safeName}.jpg` + : `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg` + imageEntries.push({ filename, url }) + } + } + + // 场景图片:取已选中的那张 + for (const location of locations) { + const selectedImage = location.images?.find(img => img.isSelected) ?? location.images?.[0] + const url = selectedImage?.imageUrl + if (!url) continue + const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_') + imageEntries.push({ filename: `locations/${safeName}.jpg`, url }) + } + + if (imageEntries.length === 0) { + alert(t('assetLibrary.downloadEmpty')) + return + } + + setIsDownloading(true) + try { + const zip = new JSZip() + await Promise.all( + imageEntries.map(async ({ filename, url }) => { + try { + const response = await fetch(url) + if (!response.ok) return + const blob = await response.blob() + zip.file(filename, blob) + } catch { + // 单张失败不影响其他 + } + }) + ) + const content = await zip.generateAsync({ type: 'blob' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(content) + link.download = `assets_${new Date().toISOString().slice(0, 10)}.zip` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(link.href) + } catch (error) { + _logError('打包下载失败:', error) + alert(t('assetLibrary.downloadFailed')) + } finally { + setIsDownloading(false) + } + } + return ( <> {/* 触发按钮 - 现代玻璃态风格 */} @@ -48,6 +120,20 @@ export default function AssetLibrary({

{t('assetLibrary.title')}

+ + {/* 下载按钮 - 紧贴标题 */} + + {/* 打包下载按钮 */} + diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterCard.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterCard.tsx index 88a4e09..db8ace2 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterCard.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterCard.tsx @@ -21,6 +21,8 @@ import CharacterCardActions from './character-card/CharacterCardActions' import { getImageGenerationCountOptions } from '@/lib/image-generation/count' import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count' import { AppIcon } from '@/components/ui/icons' +import { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style' +import AISparklesIcon from '@/components/ui/icons/AISparklesIcon' interface CharacterCardProps { character: Character @@ -218,15 +220,18 @@ export default function CharacterCard({ prefix={isGroupTaskRunning ? ( ) : ( - + <> + + {t('image.regenCountPrefix')} + )} - suffix={null} + suffix={{t('image.regenCountSuffix')}} value={generationCount} options={getImageGenerationCountOptions('character')} onValueChange={setGenerationCount} onClick={() => onRegenerate(generationCount)} disabled={isAppearanceTaskRunning || isAnyTaskRunning || uploadImage.isPending} - ariaLabel={t('image.selectCount')} + ariaLabel={t('image.regenCountAriaLabel')} className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50" selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors" /> @@ -333,11 +338,10 @@ export default function CharacterCard({ {!isAppearanceTaskRunning && !isAnyTaskRunning && currentImageUrl && onImageEdit && ( )} )} diff --git a/src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx b/src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx index f1f4119..1fe991e 100644 --- a/src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx +++ b/src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx @@ -10,6 +10,8 @@ import { resolveTaskPresentationState } from '@/lib/task/presentation' import { AppIcon } from '@/components/ui/icons' import { SegmentedControl } from '@/components/ui/SegmentedControl' + + interface Character { id: string name: string @@ -67,6 +69,8 @@ interface AssetGridProps { onAddCharacter: () => void onAddLocation: () => void onAddVoice: () => void + onDownloadAll?: () => void + isDownloading?: boolean selectedFolderId: string | null onImageClick?: (url: string) => void onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void @@ -89,6 +93,8 @@ export function AssetGrid({ onAddCharacter, onAddLocation, onAddVoice, + onDownloadAll, + isDownloading, selectedFolderId: _selectedFolderId, onImageClick, onImageEdit, @@ -192,8 +198,19 @@ export function AssetGrid({ ) })()} - {/* 右侧新建按钮 */} + {/* 右侧操作按钮 */}
+ {onDownloadAll && ( + + )}